diff options
Diffstat (limited to 'src/jarabe/journal')
-rw-r--r-- | src/jarabe/journal/Makefile.am | 18 | ||||
-rw-r--r-- | src/jarabe/journal/Makefile.in | 455 | ||||
-rw-r--r-- | src/jarabe/journal/__init__.py | 15 | ||||
-rw-r--r-- | src/jarabe/journal/detailview.py | 119 | ||||
-rw-r--r-- | src/jarabe/journal/expandedentry.py | 440 | ||||
-rw-r--r-- | src/jarabe/journal/journalactivity.py | 375 | ||||
-rw-r--r-- | src/jarabe/journal/journalentrybundle.py | 94 | ||||
-rw-r--r-- | src/jarabe/journal/journaltoolbox.py | 572 | ||||
-rw-r--r-- | src/jarabe/journal/journalwindow.py | 33 | ||||
-rw-r--r-- | src/jarabe/journal/keepicon.py | 64 | ||||
-rw-r--r-- | src/jarabe/journal/listmodel.py | 243 | ||||
-rw-r--r-- | src/jarabe/journal/listview.py | 670 | ||||
-rw-r--r-- | src/jarabe/journal/misc.py | 315 | ||||
-rw-r--r-- | src/jarabe/journal/modalalert.py | 96 | ||||
-rw-r--r-- | src/jarabe/journal/model.py | 818 | ||||
-rw-r--r-- | src/jarabe/journal/objectchooser.py | 199 | ||||
-rw-r--r-- | src/jarabe/journal/palettes.py | 383 | ||||
-rw-r--r-- | src/jarabe/journal/volumestoolbar.py | 404 |
18 files changed, 5313 insertions, 0 deletions
diff --git a/src/jarabe/journal/Makefile.am b/src/jarabe/journal/Makefile.am new file mode 100644 index 0000000..ba29062 --- /dev/null +++ b/src/jarabe/journal/Makefile.am @@ -0,0 +1,18 @@ +sugardir = $(pythondir)/jarabe/journal +sugar_PYTHON = \ + __init__.py \ + detailview.py \ + expandedentry.py \ + journalactivity.py \ + journalentrybundle.py \ + journaltoolbox.py \ + journalwindow.py \ + keepicon.py \ + listmodel.py \ + listview.py \ + misc.py \ + modalalert.py \ + model.py \ + objectchooser.py \ + palettes.py \ + volumestoolbar.py diff --git a/src/jarabe/journal/Makefile.in b/src/jarabe/journal/Makefile.in new file mode 100644 index 0000000..8d51c6b --- /dev/null +++ b/src/jarabe/journal/Makefile.in @@ -0,0 +1,455 @@ +# Makefile.in generated by automake 1.11.3 from Makefile.am. +# @configure_input@ + +# Copyright (C) 1994, 1995, 1996, 1997, 1998, 1999, 2000, 2001, 2002, +# 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, 2011 Free Software +# Foundation, Inc. +# This Makefile.in is free software; the Free Software Foundation +# gives unlimited permission to copy and/or distribute it, +# with or without modifications, as long as this notice is preserved. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY, to the extent permitted by law; without +# even the implied warranty of MERCHANTABILITY or FITNESS FOR A +# PARTICULAR PURPOSE. + +@SET_MAKE@ +VPATH = @srcdir@ +pkgdatadir = $(datadir)/@PACKAGE@ +pkgincludedir = $(includedir)/@PACKAGE@ +pkglibdir = $(libdir)/@PACKAGE@ +pkglibexecdir = $(libexecdir)/@PACKAGE@ +am__cd = CDPATH="$${ZSH_VERSION+.}$(PATH_SEPARATOR)" && cd +install_sh_DATA = $(install_sh) -c -m 644 +install_sh_PROGRAM = $(install_sh) -c +install_sh_SCRIPT = $(install_sh) -c +INSTALL_HEADER = $(INSTALL_DATA) +transform = $(program_transform_name) +NORMAL_INSTALL = : +PRE_INSTALL = : +POST_INSTALL = : +NORMAL_UNINSTALL = : +PRE_UNINSTALL = : +POST_UNINSTALL = : +subdir = src/jarabe/journal +DIST_COMMON = $(srcdir)/Makefile.am $(srcdir)/Makefile.in \ + $(sugar_PYTHON) +ACLOCAL_M4 = $(top_srcdir)/aclocal.m4 +am__aclocal_m4_deps = $(top_srcdir)/configure.ac +am__configure_deps = $(am__aclocal_m4_deps) $(CONFIGURE_DEPENDENCIES) \ + $(ACLOCAL_M4) +mkinstalldirs = $(install_sh) -d +CONFIG_CLEAN_FILES = +CONFIG_CLEAN_VPATH_FILES = +SOURCES = +DIST_SOURCES = +am__vpath_adj_setup = srcdirstrip=`echo "$(srcdir)" | sed 's|.|.|g'`; +am__vpath_adj = case $$p in \ + $(srcdir)/*) f=`echo "$$p" | sed "s|^$$srcdirstrip/||"`;; \ + *) f=$$p;; \ + esac; +am__strip_dir = f=`echo $$p | sed -e 's|^.*/||'`; +am__install_max = 40 +am__nobase_strip_setup = \ + srcdirstrip=`echo "$(srcdir)" | sed 's/[].[^$$\\*|]/\\\\&/g'` +am__nobase_strip = \ + for p in $$list; do echo "$$p"; done | sed -e "s|$$srcdirstrip/||" +am__nobase_list = $(am__nobase_strip_setup); \ + for p in $$list; do echo "$$p $$p"; done | \ + sed "s| $$srcdirstrip/| |;"' / .*\//!s/ .*/ ./; s,\( .*\)/[^/]*$$,\1,' | \ + $(AWK) 'BEGIN { files["."] = "" } { files[$$2] = files[$$2] " " $$1; \ + if (++n[$$2] == $(am__install_max)) \ + { print $$2, files[$$2]; n[$$2] = 0; files[$$2] = "" } } \ + END { for (dir in files) print dir, files[dir] }' +am__base_list = \ + sed '$$!N;$$!N;$$!N;$$!N;$$!N;$$!N;$$!N;s/\n/ /g' | \ + sed '$$!N;$$!N;$$!N;$$!N;s/\n/ /g' +am__uninstall_files_from_dir = { \ + test -z "$$files" \ + || { test ! -d "$$dir" && test ! -f "$$dir" && test ! -r "$$dir"; } \ + || { echo " ( cd '$$dir' && rm -f" $$files ")"; \ + $(am__cd) "$$dir" && rm -f $$files; }; \ + } +am__py_compile = PYTHON=$(PYTHON) $(SHELL) $(py_compile) +am__installdirs = "$(DESTDIR)$(sugardir)" +py_compile = $(top_srcdir)/py-compile +DISTFILES = $(DIST_COMMON) $(DIST_SOURCES) $(TEXINFOS) $(EXTRA_DIST) +ACLOCAL = @ACLOCAL@ +ALL_LINGUAS = @ALL_LINGUAS@ +AMTAR = @AMTAR@ +AM_DEFAULT_VERBOSITY = @AM_DEFAULT_VERBOSITY@ +AUTOCONF = @AUTOCONF@ +AUTOHEADER = @AUTOHEADER@ +AUTOMAKE = @AUTOMAKE@ +AWK = @AWK@ +CATALOGS = @CATALOGS@ +CATOBJEXT = @CATOBJEXT@ +CC = @CC@ +CCDEPMODE = @CCDEPMODE@ +CFLAGS = @CFLAGS@ +CPP = @CPP@ +CPPFLAGS = @CPPFLAGS@ +CYGPATH_W = @CYGPATH_W@ +DATADIRNAME = @DATADIRNAME@ +DEFS = @DEFS@ +DEPDIR = @DEPDIR@ +ECHO_C = @ECHO_C@ +ECHO_N = @ECHO_N@ +ECHO_T = @ECHO_T@ +EGREP = @EGREP@ +EXEEXT = @EXEEXT@ +GCONFTOOL = @GCONFTOOL@ +GCONF_SCHEMA_CONFIG_SOURCE = @GCONF_SCHEMA_CONFIG_SOURCE@ +GCONF_SCHEMA_FILE_DIR = @GCONF_SCHEMA_FILE_DIR@ +GETTEXT_PACKAGE = @GETTEXT_PACKAGE@ +GMOFILES = @GMOFILES@ +GMSGFMT = @GMSGFMT@ +GREP = @GREP@ +INSTALL = @INSTALL@ +INSTALL_DATA = @INSTALL_DATA@ +INSTALL_PROGRAM = @INSTALL_PROGRAM@ +INSTALL_SCRIPT = @INSTALL_SCRIPT@ +INSTALL_STRIP_PROGRAM = @INSTALL_STRIP_PROGRAM@ +INSTOBJEXT = @INSTOBJEXT@ +INTLLIBS = @INTLLIBS@ +INTLTOOL_EXTRACT = @INTLTOOL_EXTRACT@ +INTLTOOL_MERGE = @INTLTOOL_MERGE@ +INTLTOOL_PERL = @INTLTOOL_PERL@ +INTLTOOL_UPDATE = @INTLTOOL_UPDATE@ +INTLTOOL_V_MERGE = @INTLTOOL_V_MERGE@ +INTLTOOL_V_MERGE_OPTIONS = @INTLTOOL_V_MERGE_OPTIONS@ +INTLTOOL__v_MERGE_ = @INTLTOOL__v_MERGE_@ +INTLTOOL__v_MERGE_0 = @INTLTOOL__v_MERGE_0@ +LDFLAGS = @LDFLAGS@ +LIBOBJS = @LIBOBJS@ +LIBS = @LIBS@ +LTLIBOBJS = @LTLIBOBJS@ +MAINT = @MAINT@ +MAKEINFO = @MAKEINFO@ +MKDIR_P = @MKDIR_P@ +MKINSTALLDIRS = @MKINSTALLDIRS@ +MSGFMT = @MSGFMT@ +MSGFMT_OPTS = @MSGFMT_OPTS@ +MSGMERGE = @MSGMERGE@ +OBJEXT = @OBJEXT@ +PACKAGE = @PACKAGE@ +PACKAGE_BUGREPORT = @PACKAGE_BUGREPORT@ +PACKAGE_NAME = @PACKAGE_NAME@ +PACKAGE_STRING = @PACKAGE_STRING@ +PACKAGE_TARNAME = @PACKAGE_TARNAME@ +PACKAGE_URL = @PACKAGE_URL@ +PACKAGE_VERSION = @PACKAGE_VERSION@ +PATH_SEPARATOR = @PATH_SEPARATOR@ +PKG_CONFIG = @PKG_CONFIG@ +PKG_CONFIG_LIBDIR = @PKG_CONFIG_LIBDIR@ +PKG_CONFIG_PATH = @PKG_CONFIG_PATH@ +POFILES = @POFILES@ +POSUB = @POSUB@ +PO_IN_DATADIR_FALSE = @PO_IN_DATADIR_FALSE@ +PO_IN_DATADIR_TRUE = @PO_IN_DATADIR_TRUE@ +PYTHON = @PYTHON@ +PYTHON_EXEC_PREFIX = @PYTHON_EXEC_PREFIX@ +PYTHON_PLATFORM = @PYTHON_PLATFORM@ +PYTHON_PREFIX = @PYTHON_PREFIX@ +PYTHON_VERSION = @PYTHON_VERSION@ +SET_MAKE = @SET_MAKE@ +SHELL = @SHELL@ +SHELL_CFLAGS = @SHELL_CFLAGS@ +SHELL_LIBS = @SHELL_LIBS@ +STRIP = @STRIP@ +SUCROSE_VERSION = @SUCROSE_VERSION@ +USE_NLS = @USE_NLS@ +VERSION = @VERSION@ +XGETTEXT = @XGETTEXT@ +abs_builddir = @abs_builddir@ +abs_srcdir = @abs_srcdir@ +abs_top_builddir = @abs_top_builddir@ +abs_top_srcdir = @abs_top_srcdir@ +ac_ct_CC = @ac_ct_CC@ +am__include = @am__include@ +am__leading_dot = @am__leading_dot@ +am__quote = @am__quote@ +am__tar = @am__tar@ +am__untar = @am__untar@ +bindir = @bindir@ +build_alias = @build_alias@ +builddir = @builddir@ +datadir = @datadir@ +datarootdir = @datarootdir@ +docdir = @docdir@ +dvidir = @dvidir@ +exec_prefix = @exec_prefix@ +host_alias = @host_alias@ +htmldir = @htmldir@ +includedir = @includedir@ +infodir = @infodir@ +install_sh = @install_sh@ +intltool__v_merge_options_ = @intltool__v_merge_options_@ +intltool__v_merge_options_0 = @intltool__v_merge_options_0@ +libdir = @libdir@ +libexecdir = @libexecdir@ +localedir = @localedir@ +localstatedir = @localstatedir@ +mandir = @mandir@ +mkdir_p = @mkdir_p@ +oldincludedir = @oldincludedir@ +pdfdir = @pdfdir@ +pkgpyexecdir = @pkgpyexecdir@ +pkgpythondir = @pkgpythondir@ +prefix = @prefix@ +program_transform_name = @program_transform_name@ +psdir = @psdir@ +pyexecdir = @pyexecdir@ +pythondir = @pythondir@ +sbindir = @sbindir@ +sharedstatedir = @sharedstatedir@ +srcdir = @srcdir@ +sysconfdir = @sysconfdir@ +target_alias = @target_alias@ +top_build_prefix = @top_build_prefix@ +top_builddir = @top_builddir@ +top_srcdir = @top_srcdir@ +sugardir = $(pythondir)/jarabe/journal +sugar_PYTHON = \ + __init__.py \ + detailview.py \ + expandedentry.py \ + journalactivity.py \ + journalentrybundle.py \ + journaltoolbox.py \ + journalwindow.py \ + keepicon.py \ + listmodel.py \ + listview.py \ + misc.py \ + modalalert.py \ + model.py \ + objectchooser.py \ + palettes.py \ + volumestoolbar.py + +all: all-am + +.SUFFIXES: +$(srcdir)/Makefile.in: @MAINTAINER_MODE_TRUE@ $(srcdir)/Makefile.am $(am__configure_deps) + @for dep in $?; do \ + case '$(am__configure_deps)' in \ + *$$dep*) \ + ( cd $(top_builddir) && $(MAKE) $(AM_MAKEFLAGS) am--refresh ) \ + && { if test -f $@; then exit 0; else break; fi; }; \ + exit 1;; \ + esac; \ + done; \ + echo ' cd $(top_srcdir) && $(AUTOMAKE) --foreign src/jarabe/journal/Makefile'; \ + $(am__cd) $(top_srcdir) && \ + $(AUTOMAKE) --foreign src/jarabe/journal/Makefile +.PRECIOUS: Makefile +Makefile: $(srcdir)/Makefile.in $(top_builddir)/config.status + @case '$?' in \ + *config.status*) \ + cd $(top_builddir) && $(MAKE) $(AM_MAKEFLAGS) am--refresh;; \ + *) \ + echo ' cd $(top_builddir) && $(SHELL) ./config.status $(subdir)/$@ $(am__depfiles_maybe)'; \ + cd $(top_builddir) && $(SHELL) ./config.status $(subdir)/$@ $(am__depfiles_maybe);; \ + esac; + +$(top_builddir)/config.status: $(top_srcdir)/configure $(CONFIG_STATUS_DEPENDENCIES) + cd $(top_builddir) && $(MAKE) $(AM_MAKEFLAGS) am--refresh + +$(top_srcdir)/configure: @MAINTAINER_MODE_TRUE@ $(am__configure_deps) + cd $(top_builddir) && $(MAKE) $(AM_MAKEFLAGS) am--refresh +$(ACLOCAL_M4): @MAINTAINER_MODE_TRUE@ $(am__aclocal_m4_deps) + cd $(top_builddir) && $(MAKE) $(AM_MAKEFLAGS) am--refresh +$(am__aclocal_m4_deps): +install-sugarPYTHON: $(sugar_PYTHON) + @$(NORMAL_INSTALL) + test -z "$(sugardir)" || $(MKDIR_P) "$(DESTDIR)$(sugardir)" + @list='$(sugar_PYTHON)'; dlist=; list2=; test -n "$(sugardir)" || list=; \ + for p in $$list; do \ + if test -f "$$p"; then b=; else b="$(srcdir)/"; fi; \ + if test -f $$b$$p; then \ + $(am__strip_dir) \ + dlist="$$dlist $$f"; \ + list2="$$list2 $$b$$p"; \ + else :; fi; \ + done; \ + for file in $$list2; do echo $$file; done | $(am__base_list) | \ + while read files; do \ + echo " $(INSTALL_DATA) $$files '$(DESTDIR)$(sugardir)'"; \ + $(INSTALL_DATA) $$files "$(DESTDIR)$(sugardir)" || exit $$?; \ + done || exit $$?; \ + if test -n "$$dlist"; then \ + $(am__py_compile) --destdir "$(DESTDIR)" \ + --basedir "$(sugardir)" $$dlist; \ + else :; fi + +uninstall-sugarPYTHON: + @$(NORMAL_UNINSTALL) + @list='$(sugar_PYTHON)'; test -n "$(sugardir)" || list=; \ + files=`for p in $$list; do echo $$p; done | sed -e 's|^.*/||'`; \ + test -n "$$files" || exit 0; \ + dir='$(DESTDIR)$(sugardir)'; \ + filesc=`echo "$$files" | sed 's|$$|c|'`; \ + fileso=`echo "$$files" | sed 's|$$|o|'`; \ + st=0; \ + for files in "$$files" "$$filesc" "$$fileso"; do \ + $(am__uninstall_files_from_dir) || st=$$?; \ + done; \ + exit $$st +tags: TAGS +TAGS: + +ctags: CTAGS +CTAGS: + + +distdir: $(DISTFILES) + @srcdirstrip=`echo "$(srcdir)" | sed 's/[].[^$$\\*]/\\\\&/g'`; \ + topsrcdirstrip=`echo "$(top_srcdir)" | sed 's/[].[^$$\\*]/\\\\&/g'`; \ + list='$(DISTFILES)'; \ + dist_files=`for file in $$list; do echo $$file; done | \ + sed -e "s|^$$srcdirstrip/||;t" \ + -e "s|^$$topsrcdirstrip/|$(top_builddir)/|;t"`; \ + case $$dist_files in \ + */*) $(MKDIR_P) `echo "$$dist_files" | \ + sed '/\//!d;s|^|$(distdir)/|;s,/[^/]*$$,,' | \ + sort -u` ;; \ + esac; \ + for file in $$dist_files; do \ + if test -f $$file || test -d $$file; then d=.; else d=$(srcdir); fi; \ + if test -d $$d/$$file; then \ + dir=`echo "/$$file" | sed -e 's,/[^/]*$$,,'`; \ + if test -d "$(distdir)/$$file"; then \ + find "$(distdir)/$$file" -type d ! -perm -700 -exec chmod u+rwx {} \;; \ + fi; \ + if test -d $(srcdir)/$$file && test $$d != $(srcdir); then \ + cp -fpR $(srcdir)/$$file "$(distdir)$$dir" || exit 1; \ + find "$(distdir)/$$file" -type d ! -perm -700 -exec chmod u+rwx {} \;; \ + fi; \ + cp -fpR $$d/$$file "$(distdir)$$dir" || exit 1; \ + else \ + test -f "$(distdir)/$$file" \ + || cp -p $$d/$$file "$(distdir)/$$file" \ + || exit 1; \ + fi; \ + done +check-am: all-am +check: check-am +all-am: Makefile +installdirs: + for dir in "$(DESTDIR)$(sugardir)"; do \ + test -z "$$dir" || $(MKDIR_P) "$$dir"; \ + done +install: install-am +install-exec: install-exec-am +install-data: install-data-am +uninstall: uninstall-am + +install-am: all-am + @$(MAKE) $(AM_MAKEFLAGS) install-exec-am install-data-am + +installcheck: installcheck-am +install-strip: + if test -z '$(STRIP)'; then \ + $(MAKE) $(AM_MAKEFLAGS) INSTALL_PROGRAM="$(INSTALL_STRIP_PROGRAM)" \ + install_sh_PROGRAM="$(INSTALL_STRIP_PROGRAM)" INSTALL_STRIP_FLAG=-s \ + install; \ + else \ + $(MAKE) $(AM_MAKEFLAGS) INSTALL_PROGRAM="$(INSTALL_STRIP_PROGRAM)" \ + install_sh_PROGRAM="$(INSTALL_STRIP_PROGRAM)" INSTALL_STRIP_FLAG=-s \ + "INSTALL_PROGRAM_ENV=STRIPPROG='$(STRIP)'" install; \ + fi +mostlyclean-generic: + +clean-generic: + +distclean-generic: + -test -z "$(CONFIG_CLEAN_FILES)" || rm -f $(CONFIG_CLEAN_FILES) + -test . = "$(srcdir)" || test -z "$(CONFIG_CLEAN_VPATH_FILES)" || rm -f $(CONFIG_CLEAN_VPATH_FILES) + +maintainer-clean-generic: + @echo "This command is intended for maintainers to use" + @echo "it deletes files that may require special tools to rebuild." +clean: clean-am + +clean-am: clean-generic mostlyclean-am + +distclean: distclean-am + -rm -f Makefile +distclean-am: clean-am distclean-generic + +dvi: dvi-am + +dvi-am: + +html: html-am + +html-am: + +info: info-am + +info-am: + +install-data-am: install-sugarPYTHON + +install-dvi: install-dvi-am + +install-dvi-am: + +install-exec-am: + +install-html: install-html-am + +install-html-am: + +install-info: install-info-am + +install-info-am: + +install-man: + +install-pdf: install-pdf-am + +install-pdf-am: + +install-ps: install-ps-am + +install-ps-am: + +installcheck-am: + +maintainer-clean: maintainer-clean-am + -rm -f Makefile +maintainer-clean-am: distclean-am maintainer-clean-generic + +mostlyclean: mostlyclean-am + +mostlyclean-am: mostlyclean-generic + +pdf: pdf-am + +pdf-am: + +ps: ps-am + +ps-am: + +uninstall-am: uninstall-sugarPYTHON + +.MAKE: install-am install-strip + +.PHONY: all all-am check check-am clean clean-generic distclean \ + distclean-generic distdir dvi dvi-am html html-am info info-am \ + install install-am install-data install-data-am install-dvi \ + install-dvi-am install-exec install-exec-am install-html \ + install-html-am install-info install-info-am install-man \ + install-pdf install-pdf-am install-ps install-ps-am \ + install-strip install-sugarPYTHON installcheck installcheck-am \ + installdirs maintainer-clean maintainer-clean-generic \ + mostlyclean mostlyclean-generic pdf pdf-am ps ps-am uninstall \ + uninstall-am uninstall-sugarPYTHON + + +# Tell versions [3.59,3.63) of GNU make to not export all variables. +# Otherwise a system limit (for SysV at least) may be exceeded. +.NOEXPORT: diff --git a/src/jarabe/journal/__init__.py b/src/jarabe/journal/__init__.py new file mode 100644 index 0000000..6373228 --- /dev/null +++ b/src/jarabe/journal/__init__.py @@ -0,0 +1,15 @@ +# Copyright (C) 2007, One Laptop Per Child +# +# 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 diff --git a/src/jarabe/journal/detailview.py b/src/jarabe/journal/detailview.py new file mode 100644 index 0000000..aa8c039 --- /dev/null +++ b/src/jarabe/journal/detailview.py @@ -0,0 +1,119 @@ +# Copyright (C) 2007, One Laptop Per Child +# +# 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 logging +from gettext import gettext as _ + +import gobject +import gtk +import hippo + +from sugar.graphics import style +from sugar.graphics.icon import CanvasIcon + +from jarabe.journal.expandedentry import ExpandedEntry +from jarabe.journal import model + + +class DetailView(gtk.VBox): + __gtype_name__ = 'DetailView' + + __gsignals__ = { + 'go-back-clicked': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, ([])), + } + + def __init__(self, **kwargs): + self._metadata = None + self._expanded_entry = None + + canvas = hippo.Canvas() + + self._root = hippo.CanvasBox() + self._root.props.background_color = style.COLOR_PANEL_GREY.get_int() + canvas.set_root(self._root) + + back_bar = BackBar() + back_bar.connect('button-release-event', + self.__back_bar_release_event_cb) + self._root.append(back_bar) + + gobject.GObject.__init__(self, **kwargs) + + self.pack_start(canvas) + canvas.show() + + def _fav_icon_activated_cb(self, fav_icon): + keep = not self._expanded_entry.get_keep() + self._expanded_entry.set_keep(keep) + fav_icon.props.keep = keep + + def __back_bar_release_event_cb(self, back_bar, event): + self.emit('go-back-clicked') + return False + + def _update_view(self): + if self._expanded_entry is None: + self._expanded_entry = ExpandedEntry() + self._root.append(self._expanded_entry, hippo.PACK_EXPAND) + self._expanded_entry.set_metadata(self._metadata) + + def refresh(self): + logging.debug('DetailView.refresh') + self._metadata = model.get(self._metadata['uid']) + self._update_view() + + def get_metadata(self): + return self._metadata + + def set_metadata(self, metadata): + self._metadata = metadata + self._update_view() + + metadata = gobject.property( + type=object, getter=get_metadata, setter=set_metadata) + + +class BackBar(hippo.CanvasBox): + def __init__(self): + hippo.CanvasBox.__init__(self, + orientation=hippo.ORIENTATION_HORIZONTAL, + border=style.LINE_WIDTH, + background_color=style.COLOR_PANEL_GREY.get_int(), + border_color=style.COLOR_SELECTION_GREY.get_int(), + padding=style.DEFAULT_PADDING, + padding_left=style.DEFAULT_SPACING, + spacing=style.DEFAULT_SPACING) + + icon = CanvasIcon(icon_name='go-previous', + size=style.SMALL_ICON_SIZE, + fill_color=style.COLOR_TOOLBAR_GREY.get_svg()) + self.append(icon) + + label = hippo.CanvasText(text=_('Back'), + font_desc=style.FONT_NORMAL.get_pango_desc()) + self.append(label) + + if gtk.widget_get_default_direction() == gtk.TEXT_DIR_RTL: + self.reverse() + + self.connect('motion-notify-event', self.__motion_notify_event_cb) + + def __motion_notify_event_cb(self, box, event): + if event.detail == hippo.MOTION_DETAIL_ENTER: + box.props.background_color = style.COLOR_SELECTION_GREY.get_int() + elif event.detail == hippo.MOTION_DETAIL_LEAVE: + box.props.background_color = style.COLOR_PANEL_GREY.get_int() + return False diff --git a/src/jarabe/journal/expandedentry.py b/src/jarabe/journal/expandedentry.py new file mode 100644 index 0000000..03f8cd1 --- /dev/null +++ b/src/jarabe/journal/expandedentry.py @@ -0,0 +1,440 @@ +# Copyright (C) 2007, One Laptop Per Child +# +# 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 logging +from gettext import gettext as _ +import StringIO +import time +import os + +import hippo +import cairo +import gobject +import glib +import gtk +import simplejson + +from sugar.graphics import style +from sugar.graphics.icon import CanvasIcon +from sugar.graphics.xocolor import XoColor +from sugar.graphics.canvastextview import CanvasTextView +from sugar.util import format_size + +from jarabe.journal.keepicon import KeepIcon +from jarabe.journal.palettes import ObjectPalette, BuddyPalette +from jarabe.journal import misc +from jarabe.journal import model + + +class Separator(hippo.CanvasBox, hippo.CanvasItem): + def __init__(self, orientation): + hippo.CanvasBox.__init__(self, + background_color=style.COLOR_PANEL_GREY.get_int()) + + if orientation == hippo.ORIENTATION_VERTICAL: + self.props.box_width = style.LINE_WIDTH + else: + self.props.box_height = style.LINE_WIDTH + + +class BuddyList(hippo.CanvasBox): + def __init__(self, buddies): + hippo.CanvasBox.__init__(self, xalign=hippo.ALIGNMENT_START, + orientation=hippo.ORIENTATION_HORIZONTAL) + + for buddy in buddies: + nick_, color = buddy + hbox = hippo.CanvasBox(orientation=hippo.ORIENTATION_HORIZONTAL) + icon = CanvasIcon(icon_name='computer-xo', + xo_color=XoColor(color), + size=style.STANDARD_ICON_SIZE) + icon.set_palette(BuddyPalette(buddy)) + hbox.append(icon) + self.append(hbox) + + +class ExpandedEntry(hippo.CanvasBox): + def __init__(self): + hippo.CanvasBox.__init__(self) + self.props.orientation = hippo.ORIENTATION_VERTICAL + self.props.background_color = style.COLOR_WHITE.get_int() + self.props.padding_top = style.DEFAULT_SPACING * 3 + + self._metadata = None + self._update_title_sid = None + + # Create header + header = hippo.CanvasBox(orientation=hippo.ORIENTATION_HORIZONTAL, + padding=style.DEFAULT_PADDING, + padding_right=style.GRID_CELL_SIZE, + spacing=style.DEFAULT_SPACING) + self.append(header) + + # Create two column body + + body = hippo.CanvasBox(orientation=hippo.ORIENTATION_HORIZONTAL, + spacing=style.DEFAULT_SPACING * 3, + padding_left=style.GRID_CELL_SIZE, + padding_right=style.GRID_CELL_SIZE, + padding_top=style.DEFAULT_SPACING * 3) + + self.append(body, hippo.PACK_EXPAND) + + first_column = hippo.CanvasBox(orientation=hippo.ORIENTATION_VERTICAL, + spacing=style.DEFAULT_SPACING) + body.append(first_column) + + second_column = hippo.CanvasBox(orientation=hippo.ORIENTATION_VERTICAL, + spacing=style.DEFAULT_SPACING) + body.append(second_column, hippo.PACK_EXPAND) + + # Header + + self._keep_icon = self._create_keep_icon() + header.append(self._keep_icon) + + self._icon = None + self._icon_box = hippo.CanvasBox() + header.append(self._icon_box) + + self._title = self._create_title() + header.append(self._title, hippo.PACK_EXPAND) + + # TODO: create a version list popup instead of a date label + self._date = self._create_date() + header.append(self._date) + + if gtk.widget_get_default_direction() == gtk.TEXT_DIR_RTL: + header.reverse() + + # First column + + self._preview_box = hippo.CanvasBox() + first_column.append(self._preview_box) + + self._technical_box = hippo.CanvasBox() + first_column.append(self._technical_box) + + # Second column + + description_box, self._description = self._create_description() + second_column.append(description_box) + + tags_box, self._tags = self._create_tags() + second_column.append(tags_box) + + self._buddy_list = hippo.CanvasBox() + second_column.append(self._buddy_list) + + def set_metadata(self, metadata): + if self._metadata == metadata: + return + self._metadata = metadata + + self._keep_icon.keep = (str(metadata.get('keep', 0)) == '1') + + self._icon = self._create_icon() + self._icon_box.clear() + self._icon_box.append(self._icon) + + self._date.props.text = misc.get_date(metadata) + + title = self._title.props.widget + title.props.text = metadata.get('title', _('Untitled')) + title.props.editable = model.is_editable(metadata) + + self._preview_box.clear() + self._preview_box.append(self._create_preview()) + + self._technical_box.clear() + self._technical_box.append(self._create_technical()) + + self._buddy_list.clear() + self._buddy_list.append(self._create_buddy_list()) + + description = self._description.text_view_widget + description.props.buffer.props.text = metadata.get('description', '') + description.props.editable = model.is_editable(metadata) + + tags = self._tags.text_view_widget + tags.props.buffer.props.text = metadata.get('tags', '') + tags.props.editable = model.is_editable(metadata) + + def _create_keep_icon(self): + keep_icon = KeepIcon(False) + keep_icon.connect('activated', self._keep_icon_activated_cb) + return keep_icon + + def _create_icon(self): + icon = CanvasIcon(file_name=misc.get_icon_name(self._metadata)) + icon.connect_after('button-release-event', + self._icon_button_release_event_cb) + + if misc.is_activity_bundle(self._metadata): + xo_color = XoColor('%s,%s' % (style.COLOR_BUTTON_GREY.get_svg(), + style.COLOR_TRANSPARENT.get_svg())) + else: + xo_color = misc.get_icon_color(self._metadata) + icon.props.xo_color = xo_color + + icon.set_palette(ObjectPalette(self._metadata)) + + return icon + + def _create_title(self): + entry = gtk.Entry() + entry.connect('focus-out-event', self._title_focus_out_event_cb) + + bg_color = style.COLOR_WHITE.get_gdk_color() + entry.modify_bg(gtk.STATE_INSENSITIVE, bg_color) + entry.modify_base(gtk.STATE_INSENSITIVE, bg_color) + + return hippo.CanvasWidget(widget=entry) + + def _create_date(self): + date = hippo.CanvasText(xalign=hippo.ALIGNMENT_START, + font_desc=style.FONT_NORMAL.get_pango_desc()) + return date + + def _create_preview(self): + width = style.zoom(320) + height = style.zoom(240) + box = hippo.CanvasBox() + + if len(self._metadata.get('preview', '')) > 4: + if self._metadata['preview'][1:4] == 'PNG': + preview_data = self._metadata['preview'] + else: + # TODO: We are close to be able to drop this. + import base64 + preview_data = base64.b64decode( + self._metadata['preview']) + + png_file = StringIO.StringIO(preview_data) + try: + surface = cairo.ImageSurface.create_from_png(png_file) + has_preview = True + except Exception: + logging.exception('Error while loading the preview') + has_preview = False + else: + has_preview = False + + if has_preview: + preview_box = hippo.CanvasImage(image=surface, + border=style.LINE_WIDTH, + border_color=style.COLOR_BUTTON_GREY.get_int(), + xalign=hippo.ALIGNMENT_CENTER, + yalign=hippo.ALIGNMENT_CENTER, + scale_width=width, + scale_height=height) + else: + preview_box = hippo.CanvasText(text=_('No preview'), + font_desc=style.FONT_NORMAL.get_pango_desc(), + xalign=hippo.ALIGNMENT_CENTER, + yalign=hippo.ALIGNMENT_CENTER, + border=style.LINE_WIDTH, + border_color=style.COLOR_BUTTON_GREY.get_int(), + color=style.COLOR_BUTTON_GREY.get_int(), + box_width=width, + box_height=height) + preview_box.connect_after('button-release-event', + self._preview_box_button_release_event_cb) + box.append(preview_box) + return box + + def _create_technical(self): + vbox = hippo.CanvasBox() + vbox.props.spacing = style.DEFAULT_SPACING + + lines = [ + _('Kind: %s') % (self._metadata.get('mime_type') or _('Unknown'),), + _('Date: %s') % (self._format_date(),), + _('Size: %s') % (format_size(int(self._metadata.get('filesize', + model.get_file_size(self._metadata['uid']))))), + ] + + for line in lines: + text = hippo.CanvasText(text=line, + font_desc=style.FONT_NORMAL.get_pango_desc()) + text.props.color = style.COLOR_BUTTON_GREY.get_int() + + if gtk.widget_get_default_direction() == gtk.TEXT_DIR_RTL: + text.props.xalign = hippo.ALIGNMENT_END + else: + text.props.xalign = hippo.ALIGNMENT_START + + vbox.append(text) + + return vbox + + def _format_date(self): + if 'timestamp' in self._metadata: + try: + timestamp = float(self._metadata['timestamp']) + except (ValueError, TypeError): + logging.warning('Invalid timestamp for %r: %r', + self._metadata['uid'], + self._metadata['timestamp']) + else: + return time.strftime('%x', time.localtime(timestamp)) + return _('No date') + + def _create_buddy_list(self): + + vbox = hippo.CanvasBox() + vbox.props.spacing = style.DEFAULT_SPACING + + text = hippo.CanvasText(text=_('Participants:'), + font_desc=style.FONT_NORMAL.get_pango_desc()) + text.props.color = style.COLOR_BUTTON_GREY.get_int() + + if gtk.widget_get_default_direction() == gtk.TEXT_DIR_RTL: + text.props.xalign = hippo.ALIGNMENT_END + else: + text.props.xalign = hippo.ALIGNMENT_START + + vbox.append(text) + + if self._metadata.get('buddies'): + buddies = simplejson.loads(self._metadata['buddies']).values() + vbox.append(BuddyList(buddies)) + return vbox + else: + return vbox + + def _create_description(self): + vbox = hippo.CanvasBox() + vbox.props.spacing = style.DEFAULT_SPACING + + text = hippo.CanvasText(text=_('Description:'), + font_desc=style.FONT_NORMAL.get_pango_desc()) + text.props.color = style.COLOR_BUTTON_GREY.get_int() + + if gtk.widget_get_default_direction() == gtk.TEXT_DIR_RTL: + text.props.xalign = hippo.ALIGNMENT_END + else: + text.props.xalign = hippo.ALIGNMENT_START + + vbox.append(text) + + text_view = CanvasTextView('', + box_height=style.GRID_CELL_SIZE * 2) + vbox.append(text_view, hippo.PACK_EXPAND) + + text_view.text_view_widget.props.accepts_tab = False + text_view.text_view_widget.connect('focus-out-event', + self._description_focus_out_event_cb) + + return vbox, text_view + + def _create_tags(self): + vbox = hippo.CanvasBox() + vbox.props.spacing = style.DEFAULT_SPACING + + text = hippo.CanvasText(text=_('Tags:'), + font_desc=style.FONT_NORMAL.get_pango_desc()) + text.props.color = style.COLOR_BUTTON_GREY.get_int() + + if gtk.widget_get_default_direction() == gtk.TEXT_DIR_RTL: + text.props.xalign = hippo.ALIGNMENT_END + else: + text.props.xalign = hippo.ALIGNMENT_START + + vbox.append(text) + + text_view = CanvasTextView('', + box_height=style.GRID_CELL_SIZE * 2) + vbox.append(text_view, hippo.PACK_EXPAND) + + text_view.text_view_widget.props.accepts_tab = False + text_view.text_view_widget.connect('focus-out-event', + self._tags_focus_out_event_cb) + + return vbox, text_view + + def _title_notify_text_cb(self, entry, pspec): + if not self._update_title_sid: + self._update_title_sid = gobject.timeout_add_seconds(1, + self._update_title_cb) + + def _title_focus_out_event_cb(self, entry, event): + self._update_entry() + + def _description_focus_out_event_cb(self, text_view, event): + self._update_entry() + + def _tags_focus_out_event_cb(self, text_view, event): + self._update_entry() + + def _update_entry(self, needs_update=False): + if not model.is_editable(self._metadata): + return + + old_title = self._metadata.get('title', None) + new_title = self._title.props.widget.props.text + if old_title != new_title: + label = glib.markup_escape_text(new_title) + self._icon.palette.props.primary_text = label + self._metadata['title'] = new_title + self._metadata['title_set_by_user'] = '1' + needs_update = True + + old_tags = self._metadata.get('tags', None) + new_tags = self._tags.text_view_widget.props.buffer.props.text + if old_tags != new_tags: + self._metadata['tags'] = new_tags + needs_update = True + + old_description = self._metadata.get('description', None) + new_description = \ + self._description.text_view_widget.props.buffer.props.text + if old_description != new_description: + self._metadata['description'] = new_description + needs_update = True + + if needs_update: + if self._metadata.get('mountpoint', '/') == '/': + model.write(self._metadata, update_mtime=False) + else: + old_file_path = os.path.join(self._metadata['mountpoint'], + model.get_file_name(old_title, + self._metadata['mime_type'])) + model.write(self._metadata, file_path=old_file_path, + update_mtime=False) + + self._update_title_sid = None + + def get_keep(self): + return (str(self._metadata.get('keep', 0)) == '1') + + def _keep_icon_activated_cb(self, keep_icon): + if self.get_keep(): + self._metadata['keep'] = 0 + else: + self._metadata['keep'] = 1 + self._update_entry(needs_update=True) + keep_icon.props.keep = self.get_keep() + + def _icon_button_release_event_cb(self, button, event): + logging.debug('_icon_button_release_event_cb') + misc.resume(self._metadata) + return True + + def _preview_box_button_release_event_cb(self, button, event): + logging.debug('_preview_box_button_release_event_cb') + misc.resume(self._metadata) + return True diff --git a/src/jarabe/journal/journalactivity.py b/src/jarabe/journal/journalactivity.py new file mode 100644 index 0000000..bb1c7f6 --- /dev/null +++ b/src/jarabe/journal/journalactivity.py @@ -0,0 +1,375 @@ +# Copyright (C) 2006, Red Hat, Inc. +# Copyright (C) 2007, One Laptop Per Child +# +# 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 logging +from gettext import gettext as _ +import uuid + +import gtk +import dbus +import statvfs +import os + +from sugar.graphics.window import Window +from sugar.graphics.alert import ErrorAlert + +from sugar.bundle.bundle import ZipExtractException, RegistrationException +from sugar import env +from sugar.activity import activityfactory +from sugar import wm + +from jarabe.model import bundleregistry +from jarabe.journal.journaltoolbox import MainToolbox, DetailToolbox +from jarabe.journal.listview import ListView +from jarabe.journal.detailview import DetailView +from jarabe.journal.volumestoolbar import VolumesToolbar +from jarabe.journal import misc +from jarabe.journal.journalentrybundle import JournalEntryBundle +from jarabe.journal.objectchooser import ObjectChooser +from jarabe.journal.modalalert import ModalAlert +from jarabe.journal import model +from jarabe.journal.journalwindow import JournalWindow + + +J_DBUS_SERVICE = 'org.laptop.Journal' +J_DBUS_INTERFACE = 'org.laptop.Journal' +J_DBUS_PATH = '/org/laptop/Journal' + +_SPACE_TRESHOLD = 52428800 +_BUNDLE_ID = 'org.laptop.JournalActivity' + +_journal = None + + +class JournalActivityDBusService(dbus.service.Object): + def __init__(self, parent): + self._parent = parent + session_bus = dbus.SessionBus() + bus_name = dbus.service.BusName(J_DBUS_SERVICE, + bus=session_bus, replace_existing=False, allow_replacement=False) + logging.debug('bus_name: %r', bus_name) + dbus.service.Object.__init__(self, bus_name, J_DBUS_PATH) + + @dbus.service.method(J_DBUS_INTERFACE, + in_signature='s', out_signature='') + def ShowObject(self, object_id): + """Pop-up journal and show object with object_id""" + + logging.debug('Trying to show object %s', object_id) + + if self._parent.show_object(object_id): + self._parent.reveal() + + def _chooser_response_cb(self, chooser, response_id, chooser_id): + logging.debug('JournalActivityDBusService._chooser_response_cb') + if response_id == gtk.RESPONSE_ACCEPT: + object_id = chooser.get_selected_object_id() + self.ObjectChooserResponse(chooser_id, object_id) + else: + self.ObjectChooserCancelled(chooser_id) + chooser.destroy() + del chooser + + @dbus.service.method(J_DBUS_INTERFACE, in_signature='is', + out_signature='s') + def ChooseObject(self, parent_xid, what_filter=''): + chooser_id = uuid.uuid4().hex + if parent_xid > 0: + parent = gtk.gdk.window_foreign_new(parent_xid) + else: + parent = None + chooser = ObjectChooser(parent, what_filter) + chooser.connect('response', self._chooser_response_cb, chooser_id) + chooser.show() + + return chooser_id + + @dbus.service.signal(J_DBUS_INTERFACE, signature='ss') + def ObjectChooserResponse(self, chooser_id, object_id): + pass + + @dbus.service.signal(J_DBUS_INTERFACE, signature='s') + def ObjectChooserCancelled(self, chooser_id): + pass + + +class JournalActivity(JournalWindow): + def __init__(self): + logging.debug('STARTUP: Loading the journal') + JournalWindow.__init__(self) + + self.set_title(_('Journal')) + + self._main_view = None + self._secondary_view = None + self._list_view = None + self._detail_view = None + self._main_toolbox = None + self._detail_toolbox = None + self._volumes_toolbar = None + + self._setup_main_view() + self._setup_secondary_view() + + self.add_events(gtk.gdk.ALL_EVENTS_MASK | + gtk.gdk.VISIBILITY_NOTIFY_MASK) + self._realized_sid = self.connect('realize', self.__realize_cb) + self.connect('visibility-notify-event', + self.__visibility_notify_event_cb) + self.connect('window-state-event', self.__window_state_event_cb) + self.connect('key-press-event', self._key_press_event_cb) + self.connect('focus-in-event', self._focus_in_event_cb) + + model.created.connect(self.__model_created_cb) + model.updated.connect(self.__model_updated_cb) + model.deleted.connect(self.__model_deleted_cb) + + self._dbus_service = JournalActivityDBusService(self) + + self.iconify() + + self._critical_space_alert = None + self._check_available_space() + + def __volume_error_cb(self, gobject, message, severity): + alert = ErrorAlert(title=severity, msg=message) + alert.connect('response', self.__alert_response_cb) + self.add_alert(alert) + alert.show() + + def __alert_response_cb(self, alert, response_id): + self.remove_alert(alert) + + def __realize_cb(self, window): + wm.set_bundle_id(window.window, _BUNDLE_ID) + activity_id = activityfactory.create_activity_id() + wm.set_activity_id(window.window, str(activity_id)) + self.disconnect(self._realized_sid) + self._realized_sid = None + + def can_close(self): + return False + + def _setup_main_view(self): + self._main_toolbox = MainToolbox() + self._main_view = gtk.VBox() + + self._list_view = ListView() + self._list_view.connect('detail-clicked', self.__detail_clicked_cb) + self._list_view.connect('clear-clicked', self.__clear_clicked_cb) + self._list_view.connect('volume-error', self.__volume_error_cb) + self._main_view.pack_start(self._list_view) + self._list_view.show() + + self._volumes_toolbar = VolumesToolbar() + self._volumes_toolbar.connect('volume-changed', + self.__volume_changed_cb) + self._volumes_toolbar.connect('volume-error', self.__volume_error_cb) + self._main_view.pack_start(self._volumes_toolbar, expand=False) + + search_toolbar = self._main_toolbox.search_toolbar + search_toolbar.connect('query-changed', self._query_changed_cb) + search_toolbar.set_mount_point('/') + + def _setup_secondary_view(self): + self._secondary_view = gtk.VBox() + + self._detail_toolbox = DetailToolbox() + self._detail_toolbox.entry_toolbar.connect('volume-error', + self.__volume_error_cb) + + self._detail_view = DetailView() + self._detail_view.connect('go-back-clicked', self.__go_back_clicked_cb) + self._secondary_view.pack_end(self._detail_view) + self._detail_view.show() + + def _key_press_event_cb(self, widget, event): + keyname = gtk.gdk.keyval_name(event.keyval) + if keyname == 'Escape': + self.show_main_view() + + def __detail_clicked_cb(self, list_view, object_id): + self._show_secondary_view(object_id) + + def __clear_clicked_cb(self, list_view): + self._main_toolbox.search_toolbar.clear_query() + + def __go_back_clicked_cb(self, detail_view): + self.show_main_view() + + def _query_changed_cb(self, toolbar, query): + self._list_view.update_with_query(query) + self.show_main_view() + + def show_main_view(self): + if self.toolbar_box != self._main_toolbox: + self.set_toolbar_box(self._main_toolbox) + self._main_toolbox.show() + + if self.canvas != self._main_view: + self.set_canvas(self._main_view) + self._main_view.show() + + def _show_secondary_view(self, object_id): + metadata = model.get(object_id) + try: + self._detail_toolbox.entry_toolbar.set_metadata(metadata) + except Exception: + logging.exception('Exception while displaying entry:') + + self.set_toolbar_box(self._detail_toolbox) + self._detail_toolbox.show() + + try: + self._detail_view.props.metadata = metadata + except Exception: + logging.exception('Exception while displaying entry:') + + self.set_canvas(self._secondary_view) + self._secondary_view.show() + + def show_object(self, object_id): + metadata = model.get(object_id) + if metadata is None: + return False + else: + self._show_secondary_view(object_id) + return True + + def __volume_changed_cb(self, volume_toolbar, mount_point): + logging.debug('Selected volume: %r.', mount_point) + self._main_toolbox.search_toolbar.set_mount_point(mount_point) + self._main_toolbox.set_current_toolbar(0) + + def __model_created_cb(self, sender, **kwargs): + self._check_for_bundle(kwargs['object_id']) + self._main_toolbox.search_toolbar.refresh_filters() + self._check_available_space() + + def __model_updated_cb(self, sender, **kwargs): + self._check_for_bundle(kwargs['object_id']) + + if self.canvas == self._secondary_view and \ + kwargs['object_id'] == self._detail_view.props.metadata['uid']: + self._detail_view.refresh() + + self._check_available_space() + + def __model_deleted_cb(self, sender, **kwargs): + if self.canvas == self._secondary_view and \ + kwargs['object_id'] == self._detail_view.props.metadata['uid']: + self.show_main_view() + + def _focus_in_event_cb(self, window, event): + self.search_grab_focus() + self._list_view.update_dates() + + def _check_for_bundle(self, object_id): + registry = bundleregistry.get_registry() + + metadata = model.get(object_id) + if metadata.get('progress', '').isdigit(): + if int(metadata['progress']) < 100: + return + + bundle = misc.get_bundle(metadata) + if bundle is None: + return + + if registry.is_installed(bundle): + logging.debug('_check_for_bundle bundle already installed') + return + + if metadata['mime_type'] == JournalEntryBundle.MIME_TYPE: + # JournalEntryBundle code takes over the datastore entry and + # transforms it into the journal entry from the bundle -- we have + # nothing more to do. + try: + registry.install(bundle, metadata['uid']) + except (ZipExtractException, RegistrationException): + logging.exception('Could not install bundle %s', + bundle.get_path()) + return + + try: + registry.install(bundle) + except (ZipExtractException, RegistrationException): + logging.exception('Could not install bundle %s', bundle.get_path()) + return + + metadata['bundle_id'] = bundle.get_bundle_id() + model.write(metadata) + + def search_grab_focus(self): + search_toolbar = self._main_toolbox.search_toolbar + search_toolbar.give_entry_focus() + + def __window_state_event_cb(self, window, event): + logging.debug('window_state_event_cb %r', self) + if event.changed_mask & gtk.gdk.WINDOW_STATE_ICONIFIED: + state = event.new_window_state + visible = not state & gtk.gdk.WINDOW_STATE_ICONIFIED + self._list_view.set_is_visible(visible) + + def __visibility_notify_event_cb(self, window, event): + logging.debug('visibility_notify_event_cb %r', self) + visible = event.state != gtk.gdk.VISIBILITY_FULLY_OBSCURED + self._list_view.set_is_visible(visible) + + def _check_available_space(self): + """Check available space on device + + If the available space is below 50MB an alert will be + shown which encourages to delete old journal entries. + """ + + if self._critical_space_alert: + return + stat = os.statvfs(env.get_profile_path()) + free_space = stat[statvfs.F_BSIZE] * stat[statvfs.F_BAVAIL] + if free_space < _SPACE_TRESHOLD: + self._critical_space_alert = ModalAlert() + self._critical_space_alert.connect('destroy', + self.__alert_closed_cb) + self._critical_space_alert.show() + + def __alert_closed_cb(self, data): + self.show_main_view() + self.reveal() + self._critical_space_alert = None + + def set_active_volume(self, mount): + self._volumes_toolbar.set_active_volume(mount) + + def focus_search(self): + """Become visible and give focus to the search entry + """ + self.reveal() + self.show_main_view() + self.search_grab_focus() + + +def get_journal(): + global _journal + if _journal is None: + _journal = JournalActivity() + _journal.show() + return _journal + + +def start(): + get_journal() diff --git a/src/jarabe/journal/journalentrybundle.py b/src/jarabe/journal/journalentrybundle.py new file mode 100644 index 0000000..c220c09 --- /dev/null +++ b/src/jarabe/journal/journalentrybundle.py @@ -0,0 +1,94 @@ +# Copyright (C) 2007, One Laptop Per Child +# +# 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 tempfile +import shutil + +import simplejson +import dbus + +from sugar.bundle.bundle import Bundle, MalformedBundleException + +from jarabe.journal import model + + +class JournalEntryBundle(Bundle): + """A Journal entry bundle + + See http://wiki.laptop.org/go/Journal_entry_bundles for details + """ + + MIME_TYPE = 'application/vnd.olpc-journal-entry' + + _zipped_extension = '.xoj' + _unzipped_extension = None + _infodir = None + + def __init__(self, path): + Bundle.__init__(self, path) + + def install(self, uid=''): + if 'SUGAR_ACTIVITY_ROOT' in os.environ: + install_dir = os.path.join(os.environ['SUGAR_ACTIVITY_ROOT'], + 'data') + else: + install_dir = tempfile.gettempdir() + bundle_dir = os.path.join(install_dir, self._zip_root_dir) + temp_uid = self._zip_root_dir + self._unzip(install_dir) + try: + metadata = self._read_metadata(bundle_dir) + metadata['uid'] = uid + + preview = self._read_preview(temp_uid, bundle_dir) + if preview is not None: + metadata['preview'] = dbus.ByteArray(preview) + + file_path = os.path.join(bundle_dir, temp_uid) + model.write(metadata, file_path) + finally: + shutil.rmtree(bundle_dir, ignore_errors=True) + + def get_bundle_id(self): + return None + + def _read_metadata(self, bundle_dir): + metadata_path = os.path.join(bundle_dir, '_metadata.json') + if not os.path.exists(metadata_path): + raise MalformedBundleException( + 'Bundle must contain the file "_metadata.json"') + f = open(metadata_path, 'r') + try: + json_data = f.read() + finally: + f.close() + return simplejson.loads(json_data) + + def _read_preview(self, uid, bundle_dir): + preview_path = os.path.join(bundle_dir, 'preview', uid) + if not os.path.exists(preview_path): + return '' + f = open(preview_path, 'r') + try: + preview_data = f.read() + finally: + f.close() + return preview_data + + def is_installed(self): + # These bundles can be reinstalled as many times as desired. + return False diff --git a/src/jarabe/journal/journaltoolbox.py b/src/jarabe/journal/journaltoolbox.py new file mode 100644 index 0000000..2aa4153 --- /dev/null +++ b/src/jarabe/journal/journaltoolbox.py @@ -0,0 +1,572 @@ +# Copyright (C) 2007, One Laptop Per Child +# Copyright (C) 2009, Walter Bender +# +# 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 + +from gettext import gettext as _ +import logging +from datetime import datetime, timedelta +import os +import gconf +import time + +import gobject +import gio +import glib +import gtk + +from sugar.graphics.palette import Palette +from sugar.graphics.toolbox import Toolbox +from sugar.graphics.toolcombobox import ToolComboBox +from sugar.graphics.toolbutton import ToolButton +from sugar.graphics.toggletoolbutton import ToggleToolButton +from sugar.graphics.combobox import ComboBox +from sugar.graphics.menuitem import MenuItem +from sugar.graphics.icon import Icon +from sugar.graphics.xocolor import XoColor +from sugar.graphics import iconentry +from sugar.graphics import style +from sugar import mime + +from jarabe.model import bundleregistry +from jarabe.journal import misc +from jarabe.journal import model +from jarabe.journal.palettes import ClipboardMenu +from jarabe.journal.palettes import VolumeMenu + + +_AUTOSEARCH_TIMEOUT = 1000 + +_ACTION_ANYTIME = 0 +_ACTION_TODAY = 1 +_ACTION_SINCE_YESTERDAY = 2 +_ACTION_PAST_WEEK = 3 +_ACTION_PAST_MONTH = 4 +_ACTION_PAST_YEAR = 5 + +_ACTION_ANYTHING = 0 + +_ACTION_EVERYBODY = 0 +_ACTION_MY_FRIENDS = 1 +_ACTION_MY_CLASS = 2 + + +class MainToolbox(Toolbox): + def __init__(self): + Toolbox.__init__(self) + + self.search_toolbar = SearchToolbar() + self.search_toolbar.set_size_request(-1, style.GRID_CELL_SIZE) + self.add_toolbar(_('Search'), self.search_toolbar) + self.search_toolbar.show() + + +class SearchToolbar(gtk.Toolbar): + __gtype_name__ = 'SearchToolbar' + + __gsignals__ = { + 'query-changed': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, + ([object])), + } + + def __init__(self): + gtk.Toolbar.__init__(self) + + self._mount_point = None + + self._search_entry = iconentry.IconEntry() + self._search_entry.set_icon_from_name(iconentry.ICON_ENTRY_PRIMARY, + 'system-search') + self._search_entry.connect('activate', self._search_entry_activated_cb) + self._search_entry.connect('changed', self._search_entry_changed_cb) + self._search_entry.add_clear_button() + self._autosearch_timer = None + self._add_widget(self._search_entry, expand=True) + + self._favorite_button = ToggleToolButton('emblem-favorite') + self._favorite_button.connect('toggled', + self.__favorite_button_toggled_cb) + self.insert(self._favorite_button, -1) + self._favorite_button.show() + + self._what_search_combo = ComboBox() + self._what_combo_changed_sid = self._what_search_combo.connect( + 'changed', self._combo_changed_cb) + tool_item = ToolComboBox(self._what_search_combo) + self.insert(tool_item, -1) + tool_item.show() + + self._when_search_combo = self._get_when_search_combo() + tool_item = ToolComboBox(self._when_search_combo) + self.insert(tool_item, -1) + tool_item.show() + + self._sorting_button = SortingButton() + self._sorting_button.connect('clicked', + self.__sorting_button_clicked_cb) + self.insert(self._sorting_button, -1) + self._sorting_button.connect('sort-property-changed', + self.__sort_changed_cb) + self._sorting_button.show() + + # TODO: enable it when the DS supports saving the buddies. + #self._with_search_combo = self._get_with_search_combo() + #tool_item = ToolComboBox(self._with_search_combo) + #self.insert(tool_item, -1) + #tool_item.show() + + self._query = self._build_query() + + self.refresh_filters() + + def give_entry_focus(self): + self._search_entry.grab_focus() + + def _get_when_search_combo(self): + when_search = ComboBox() + when_search.append_item(_ACTION_ANYTIME, _('Anytime')) + when_search.append_separator() + when_search.append_item(_ACTION_TODAY, _('Today')) + when_search.append_item(_ACTION_SINCE_YESTERDAY, + _('Since yesterday')) + # TRANS: Filter entries modified during the last 7 days. + when_search.append_item(_ACTION_PAST_WEEK, _('Past week')) + # TRANS: Filter entries modified during the last 30 days. + when_search.append_item(_ACTION_PAST_MONTH, _('Past month')) + # TRANS: Filter entries modified during the last 356 days. + when_search.append_item(_ACTION_PAST_YEAR, _('Past year')) + when_search.set_active(0) + when_search.connect('changed', self._combo_changed_cb) + return when_search + + def _get_with_search_combo(self): + with_search = ComboBox() + with_search.append_item(_ACTION_EVERYBODY, _('Anyone')) + with_search.append_separator() + with_search.append_item(_ACTION_MY_FRIENDS, _('My friends')) + with_search.append_item(_ACTION_MY_CLASS, _('My class')) + with_search.append_separator() + + # TODO: Ask the model for buddies. + with_search.append_item(3, 'Dan', 'theme:xo') + + with_search.set_active(0) + with_search.connect('changed', self._combo_changed_cb) + return with_search + + def _add_widget(self, widget, expand=False): + tool_item = gtk.ToolItem() + tool_item.set_expand(expand) + + tool_item.add(widget) + widget.show() + + self.insert(tool_item, -1) + tool_item.show() + + def _build_query(self): + query = {} + + if self._mount_point: + query['mountpoints'] = [self._mount_point] + + if self._favorite_button.props.active: + query['keep'] = 1 + + if self._what_search_combo.props.value: + value = self._what_search_combo.props.value + generic_type = mime.get_generic_type(value) + if generic_type: + mime_types = generic_type.mime_types + query['mime_type'] = mime_types + else: + query['activity'] = self._what_search_combo.props.value + + if self._when_search_combo.props.value: + date_from, date_to = self._get_date_range() + query['timestamp'] = {'start': date_from, 'end': date_to} + + if self._search_entry.props.text: + text = self._search_entry.props.text.strip() + if text: + query['query'] = text + + property_, order = self._sorting_button.get_current_sort() + + if order == gtk.SORT_ASCENDING: + sign = '+' + else: + sign = '-' + query['order_by'] = [sign + property_] + + return query + + def _get_date_range(self): + today_start = datetime.today().replace(hour=0, minute=0, second=0) + right_now = datetime.today() + if self._when_search_combo.props.value == _ACTION_TODAY: + date_range = (today_start, right_now) + elif self._when_search_combo.props.value == _ACTION_SINCE_YESTERDAY: + date_range = (today_start - timedelta(1), right_now) + elif self._when_search_combo.props.value == _ACTION_PAST_WEEK: + date_range = (today_start - timedelta(7), right_now) + elif self._when_search_combo.props.value == _ACTION_PAST_MONTH: + date_range = (today_start - timedelta(30), right_now) + elif self._when_search_combo.props.value == _ACTION_PAST_YEAR: + date_range = (today_start - timedelta(356), right_now) + + return (time.mktime(date_range[0].timetuple()), + time.mktime(date_range[1].timetuple())) + + def _combo_changed_cb(self, combo): + self._update_if_needed() + + def __sort_changed_cb(self, button): + self._update_if_needed() + + def __sorting_button_clicked_cb(self, button): + self._sorting_button.palette.popup(immediate=True, state=1) + + def _update_if_needed(self): + new_query = self._build_query() + if self._query != new_query: + self._query = new_query + self.emit('query-changed', self._query) + + def _search_entry_activated_cb(self, search_entry): + if self._autosearch_timer: + gobject.source_remove(self._autosearch_timer) + new_query = self._build_query() + if self._query != new_query: + self._query = new_query + self.emit('query-changed', self._query) + + def _search_entry_changed_cb(self, search_entry): + if not search_entry.props.text: + search_entry.activate() + return + + if self._autosearch_timer: + gobject.source_remove(self._autosearch_timer) + self._autosearch_timer = gobject.timeout_add(_AUTOSEARCH_TIMEOUT, + self._autosearch_timer_cb) + + def _autosearch_timer_cb(self): + logging.debug('_autosearch_timer_cb') + self._autosearch_timer = None + self._search_entry.activate() + return False + + def set_mount_point(self, mount_point): + self._mount_point = mount_point + new_query = self._build_query() + if self._query != new_query: + self._query = new_query + self.emit('query-changed', self._query) + + def set_what_filter(self, what_filter): + combo_model = self._what_search_combo.get_model() + what_filter_index = -1 + for i in range(0, len(combo_model) - 1): + if combo_model[i][0] == what_filter: + what_filter_index = i + break + + if what_filter_index == -1: + logging.warning('what_filter %r not known', what_filter) + else: + self._what_search_combo.set_active(what_filter_index) + + def refresh_filters(self): + current_value = self._what_search_combo.props.value + current_value_index = 0 + + self._what_search_combo.handler_block(self._what_combo_changed_sid) + try: + self._what_search_combo.remove_all() + # TRANS: Item in a combo box that filters by entry type. + self._what_search_combo.append_item(_ACTION_ANYTHING, + _('Anything')) + + registry = bundleregistry.get_registry() + appended_separator = False + + types = mime.get_all_generic_types() + for generic_type in types: + if not appended_separator: + self._what_search_combo.append_separator() + appended_separator = True + self._what_search_combo.append_item( + generic_type.type_id, generic_type.name, generic_type.icon) + if generic_type.type_id == current_value: + current_value_index = \ + len(self._what_search_combo.get_model()) - 1 + + self._what_search_combo.set_active(current_value_index) + + self._what_search_combo.append_separator() + + for service_name in model.get_unique_values('activity'): + activity_info = registry.get_bundle(service_name) + if activity_info is None: + continue + + if service_name == current_value: + combo_model = self._what_search_combo.get_model() + current_value_index = len(combo_model) + + # try activity-provided icon + if os.path.exists(activity_info.get_icon()): + try: + self._what_search_combo.append_item(service_name, + activity_info.get_name(), + file_name=activity_info.get_icon()) + except glib.GError, exception: + logging.warning('Falling back to default icon for' + ' "what" filter because %r (%r) has an' + ' invalid icon: %s', + activity_info.get_name(), + str(service_name), exception) + else: + continue + + # fall back to generic icon + self._what_search_combo.append_item(service_name, + activity_info.get_name(), + icon_name='application-octet-stream') + + finally: + self._what_search_combo.handler_unblock( + self._what_combo_changed_sid) + + def __favorite_button_toggled_cb(self, favorite_button): + self._update_if_needed() + + def clear_query(self): + self._search_entry.props.text = '' + self._what_search_combo.set_active(0) + self._when_search_combo.set_active(0) + self._favorite_button.props.active = False + + +class DetailToolbox(Toolbox): + def __init__(self): + Toolbox.__init__(self) + + self.entry_toolbar = EntryToolbar() + self.add_toolbar('', self.entry_toolbar) + self.entry_toolbar.show() + + +class EntryToolbar(gtk.Toolbar): + __gsignals__ = { + 'volume-error': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, + ([str, str])), + } + + def __init__(self): + gtk.Toolbar.__init__(self) + + self._metadata = None + self._temp_file_path = None + + self._resume = ToolButton('activity-start') + self._resume.connect('clicked', self._resume_clicked_cb) + self.add(self._resume) + self._resume.show() + + client = gconf.client_get_default() + color = XoColor(client.get_string('/desktop/sugar/user/color')) + self._copy = ToolButton() + icon = Icon(icon_name='edit-copy', xo_color=color) + self._copy.set_icon_widget(icon) + icon.show() + self._copy.set_tooltip(_('Copy to')) + self._copy.connect('clicked', self._copy_clicked_cb) + self.add(self._copy) + self._copy.show() + + self._duplicate = ToolButton() + icon = Icon(icon_name='edit-duplicate', xo_color=color) + self._duplicate.set_icon_widget(icon) + self._duplicate.set_tooltip(_('Duplicate')) + self._duplicate.connect('clicked', self._duplicate_clicked_cb) + self.add(self._duplicate) + + separator = gtk.SeparatorToolItem() + self.add(separator) + separator.show() + + erase_button = ToolButton('list-remove') + erase_button.set_tooltip(_('Erase')) + erase_button.connect('clicked', self._erase_button_clicked_cb) + self.add(erase_button) + erase_button.show() + + def set_metadata(self, metadata): + self._metadata = metadata + self._refresh_copy_palette() + self._refresh_duplicate_palette() + self._refresh_resume_palette() + + def _resume_clicked_cb(self, button): + misc.resume(self._metadata) + + def _copy_clicked_cb(self, button): + button.palette.popup(immediate=True, state=Palette.SECONDARY) + + def _duplicate_clicked_cb(self, button): + file_path = model.get_file(self._metadata['uid']) + try: + model.copy(self._metadata, '/') + except IOError, e: + logging.exception('Error while copying the entry.') + self.emit('volume-error', + _('Error while copying the entry. %s') % (e.strerror, ), + _('Error')) + + def _erase_button_clicked_cb(self, button): + registry = bundleregistry.get_registry() + + bundle = misc.get_bundle(self._metadata) + if bundle is not None and registry.is_installed(bundle): + registry.uninstall(bundle) + model.delete(self._metadata['uid']) + + def _resume_menu_item_activate_cb(self, menu_item, service_name): + misc.resume(self._metadata, service_name) + + def _refresh_copy_palette(self): + palette = self._copy.get_palette() + + for menu_item in palette.menu.get_children(): + palette.menu.remove(menu_item) + menu_item.destroy() + + clipboard_menu = ClipboardMenu(self._metadata) + clipboard_menu.set_image(Icon(icon_name='toolbar-edit', + icon_size=gtk.ICON_SIZE_MENU)) + clipboard_menu.connect('volume-error', self.__volume_error_cb) + palette.menu.append(clipboard_menu) + clipboard_menu.show() + + if self._metadata['mountpoint'] != '/': + client = gconf.client_get_default() + color = XoColor(client.get_string('/desktop/sugar/user/color')) + journal_menu = VolumeMenu(self._metadata, _('Journal'), '/') + journal_menu.set_image(Icon(icon_name='activity-journal', + xo_color=color, + icon_size=gtk.ICON_SIZE_MENU)) + journal_menu.connect('volume-error', self.__volume_error_cb) + palette.menu.append(journal_menu) + journal_menu.show() + + volume_monitor = gio.volume_monitor_get() + icon_theme = gtk.icon_theme_get_default() + for mount in volume_monitor.get_mounts(): + if self._metadata['mountpoint'] == mount.get_root().get_path(): + continue + volume_menu = VolumeMenu(self._metadata, mount.get_name(), + mount.get_root().get_path()) + for name in mount.get_icon().props.names: + if icon_theme.has_icon(name): + volume_menu.set_image(Icon(icon_name=name, + icon_size=gtk.ICON_SIZE_MENU)) + break + volume_menu.connect('volume-error', self.__volume_error_cb) + palette.menu.append(volume_menu) + volume_menu.show() + + def _refresh_duplicate_palette(self): + color = misc.get_icon_color(self._metadata) + self._copy.get_icon_widget().props.xo_color = color + if self._metadata['mountpoint'] == '/': + self._duplicate.show() + icon = self._duplicate.get_icon_widget() + icon.props.xo_color = color + icon.show() + else: + self._duplicate.hide() + + def __volume_error_cb(self, menu_item, message, severity): + self.emit('volume-error', message, severity) + + def _refresh_resume_palette(self): + if self._metadata.get('activity_id', ''): + # TRANS: Action label for resuming an activity. + self._resume.set_tooltip(_('Resume')) + else: + # TRANS: Action label for starting an entry. + self._resume.set_tooltip(_('Start')) + + palette = self._resume.get_palette() + + for menu_item in palette.menu.get_children(): + palette.menu.remove(menu_item) + menu_item.destroy() + + for activity_info in misc.get_activities(self._metadata): + menu_item = MenuItem(activity_info.get_name()) + menu_item.set_image(Icon(file=activity_info.get_icon(), + icon_size=gtk.ICON_SIZE_MENU)) + menu_item.connect('activate', self._resume_menu_item_activate_cb, + activity_info.get_bundle_id()) + palette.menu.append(menu_item) + menu_item.show() + + +class SortingButton(ToolButton): + __gtype_name__ = 'JournalSortingButton' + + __gsignals__ = { + 'sort-property-changed': (gobject.SIGNAL_RUN_FIRST, + gobject.TYPE_NONE, + ([])), + } + + _SORT_OPTIONS = [ + ('timestamp', 'view-lastedit', _('Sort by date modified')), + ('creation_time', 'view-created', _('Sort by date created')), + ('filesize', 'view-size', _('Sort by size')), + ] + + def __init__(self): + ToolButton.__init__(self) + + self._property = 'timestamp' + self._order = gtk.SORT_ASCENDING + + self.props.tooltip = _('Sort view') + self.props.icon_name = 'view-lastedit' + + for property_, icon, label in self._SORT_OPTIONS: + button = MenuItem(icon_name=icon, text_label=label) + button.connect('activate', + self.__sort_type_changed_cb, + property_, + icon) + button.show() + self.props.palette.menu.insert(button, -1) + + def __sort_type_changed_cb(self, widget, property_, icon_name): + self._property = property_ + #FIXME: Implement sorting order + self._order = gtk.SORT_ASCENDING + self.emit('sort-property-changed') + + self.props.icon_name = icon_name + + def get_current_sort(self): + return (self._property, self._order) diff --git a/src/jarabe/journal/journalwindow.py b/src/jarabe/journal/journalwindow.py new file mode 100644 index 0000000..31bc790 --- /dev/null +++ b/src/jarabe/journal/journalwindow.py @@ -0,0 +1,33 @@ +#Copyright (C) 2010 Software for Education, Entertainment and Training +#Activities +# +# 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 + +from sugar.graphics.window import Window + +_journal_window = None + + +class JournalWindow(Window): + + def __init__(self): + + global _journal_window + Window.__init__(self) + _journal_window = self + + +def get_journal_window(): + return _journal_window diff --git a/src/jarabe/journal/keepicon.py b/src/jarabe/journal/keepicon.py new file mode 100644 index 0000000..5bc299b --- /dev/null +++ b/src/jarabe/journal/keepicon.py @@ -0,0 +1,64 @@ +# Copyright (C) 2006, Red Hat, Inc. +# +# 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 gobject +import hippo +import gconf + +from sugar.graphics.icon import CanvasIcon +from sugar.graphics import style +from sugar.graphics.xocolor import XoColor + + +class KeepIcon(CanvasIcon): + def __init__(self, keep): + CanvasIcon.__init__(self, icon_name='emblem-favorite', + box_width=style.GRID_CELL_SIZE * 3 / 5, + size=style.SMALL_ICON_SIZE) + self.connect('motion-notify-event', self.__motion_notify_event_cb) + + self._keep = None + self.set_keep(keep) + + def set_keep(self, keep): + if keep == self._keep: + return + + self._keep = keep + if keep: + client = gconf.client_get_default() + color = XoColor(client.get_string('/desktop/sugar/user/color')) + self.props.xo_color = color + else: + self.props.stroke_color = style.COLOR_BUTTON_GREY.get_svg() + self.props.fill_color = style.COLOR_TRANSPARENT.get_svg() + + def get_keep(self): + return self._keep + + keep = gobject.property(type=int, default=0, getter=get_keep, + setter=set_keep) + + def __motion_notify_event_cb(self, icon, event): + if not self._keep: + if event.detail == hippo.MOTION_DETAIL_ENTER: + client = gconf.client_get_default() + prelit_color = XoColor(client.get_string('/desktop/sugar/user/color')) + icon.props.stroke_color = prelit_color.get_stroke_color() + icon.props.fill_color = prelit_color.get_fill_color() + elif event.detail == hippo.MOTION_DETAIL_LEAVE: + icon.props.stroke_color = style.COLOR_BUTTON_GREY.get_svg() + icon.props.fill_color = style.COLOR_TRANSPARENT.get_svg() diff --git a/src/jarabe/journal/listmodel.py b/src/jarabe/journal/listmodel.py new file mode 100644 index 0000000..417ff61 --- /dev/null +++ b/src/jarabe/journal/listmodel.py @@ -0,0 +1,243 @@ +# Copyright (C) 2009, Tomeu Vizoso +# +# 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 logging + +import simplejson +import gobject +import gtk +from gettext import gettext as _ + +from sugar.graphics.xocolor import XoColor +from sugar.graphics import style +from sugar import util + +from jarabe.journal import model +from jarabe.journal import misc + + +DS_DBUS_SERVICE = 'org.laptop.sugar.DataStore' +DS_DBUS_INTERFACE = 'org.laptop.sugar.DataStore' +DS_DBUS_PATH = '/org/laptop/sugar/DataStore' + + +class ListModel(gtk.GenericTreeModel, gtk.TreeDragSource): + __gtype_name__ = 'JournalListModel' + + __gsignals__ = { + 'ready': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, ([])), + 'progress': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, ([])), + } + + COLUMN_UID = 0 + COLUMN_FAVORITE = 1 + COLUMN_ICON = 2 + COLUMN_ICON_COLOR = 3 + COLUMN_TITLE = 4 + COLUMN_TIMESTAMP = 5 + COLUMN_CREATION_TIME = 6 + COLUMN_FILESIZE = 7 + COLUMN_PROGRESS = 8 + COLUMN_BUDDY_1 = 9 + COLUMN_BUDDY_2 = 10 + COLUMN_BUDDY_3 = 11 + + _COLUMN_TYPES = { + COLUMN_UID: str, + COLUMN_FAVORITE: bool, + COLUMN_ICON: str, + COLUMN_ICON_COLOR: object, + COLUMN_TITLE: str, + COLUMN_TIMESTAMP: str, + COLUMN_CREATION_TIME: str, + COLUMN_FILESIZE: str, + COLUMN_PROGRESS: int, + COLUMN_BUDDY_1: object, + COLUMN_BUDDY_3: object, + COLUMN_BUDDY_2: object, + } + + _PAGE_SIZE = 10 + + def __init__(self, query): + gobject.GObject.__init__(self) + + self._last_requested_index = None + self._cached_row = None + self._result_set = model.find(query, ListModel._PAGE_SIZE) + self._temp_drag_file_path = None + + # HACK: The view will tell us that it is resizing so the model can + # avoid hitting D-Bus and disk. + self.view_is_resizing = False + + self._result_set.ready.connect(self.__result_set_ready_cb) + self._result_set.progress.connect(self.__result_set_progress_cb) + + def __result_set_ready_cb(self, **kwargs): + self.emit('ready') + + def __result_set_progress_cb(self, **kwargs): + self.emit('progress') + + def setup(self): + self._result_set.setup() + + def stop(self): + self._result_set.stop() + + def get_metadata(self, path): + return model.get(self[path][ListModel.COLUMN_UID]) + + def on_get_n_columns(self): + return len(ListModel._COLUMN_TYPES) + + def on_get_column_type(self, index): + return ListModel._COLUMN_TYPES[index] + + def on_iter_n_children(self, iterator): + if iterator == None: + return self._result_set.length + else: + return 0 + + def on_get_value(self, index, column): + if self.view_is_resizing: + return None + + if index == self._last_requested_index: + return self._cached_row[column] + + if index >= self._result_set.length: + return None + + self._result_set.seek(index) + metadata = self._result_set.read() + + self._last_requested_index = index + self._cached_row = [] + self._cached_row.append(metadata['uid']) + self._cached_row.append(metadata.get('keep', '0') == '1') + self._cached_row.append(misc.get_icon_name(metadata)) + + if misc.is_activity_bundle(metadata): + xo_color = XoColor('%s,%s' % (style.COLOR_BUTTON_GREY.get_svg(), + style.COLOR_TRANSPARENT.get_svg())) + else: + xo_color = misc.get_icon_color(metadata) + self._cached_row.append(xo_color) + + title = gobject.markup_escape_text(metadata.get('title', + _('Untitled'))) + self._cached_row.append('<b>%s</b>' % (title, )) + + try: + timestamp = float(metadata.get('timestamp', 0)) + except (TypeError, ValueError): + timestamp_content = _('Unknown') + else: + timestamp_content = util.timestamp_to_elapsed_string(timestamp) + self._cached_row.append(timestamp_content) + + try: + creation_time = float(metadata.get('creation_time')) + except (TypeError, ValueError): + self._cached_row.append(_('Unknown')) + else: + self._cached_row.append( + util.timestamp_to_elapsed_string(float(creation_time))) + + try: + size = int(metadata.get('filesize')) + except (TypeError, ValueError): + size = None + self._cached_row.append(util.format_size(size)) + + try: + progress = int(float(metadata.get('progress', 100))) + except (TypeError, ValueError): + progress = 100 + self._cached_row.append(progress) + + buddies = [] + if metadata.get('buddies'): + try: + buddies = simplejson.loads(metadata['buddies']).values() + except simplejson.decoder.JSONDecodeError, exception: + logging.warning('Cannot decode buddies for %r: %s', + metadata['uid'], exception) + + if not isinstance(buddies, list): + logging.warning('Content of buddies for %r is not a list: %r', + metadata['uid'], buddies) + buddies = [] + + for n_ in xrange(0, 3): + if buddies: + try: + nick, color = buddies.pop(0) + except (AttributeError, ValueError), exception: + logging.warning('Malformed buddies for %r: %s', + metadata['uid'], exception) + else: + self._cached_row.append((nick, XoColor(color))) + continue + + self._cached_row.append(None) + + return self._cached_row[column] + + def on_iter_nth_child(self, iterator, n): + return n + + def on_get_path(self, iterator): + return (iterator) + + def on_get_iter(self, path): + return path[0] + + def on_iter_next(self, iterator): + if iterator != None: + if iterator >= self._result_set.length - 1: + return None + return iterator + 1 + return None + + def on_get_flags(self): + return gtk.TREE_MODEL_ITERS_PERSIST | gtk.TREE_MODEL_LIST_ONLY + + def on_iter_children(self, iterator): + return None + + def on_iter_has_child(self, iterator): + return False + + def on_iter_parent(self, iterator): + return None + + def do_drag_data_get(self, path, selection): + uid = self[path][ListModel.COLUMN_UID] + if selection.target == 'text/uri-list': + # Get hold of a reference so the temp file doesn't get deleted + self._temp_drag_file_path = model.get_file(uid) + logging.debug('putting %r in selection', self._temp_drag_file_path) + selection.set(selection.target, 8, self._temp_drag_file_path) + return True + elif selection.target == 'journal-object-id': + selection.set(selection.target, 8, uid) + return True + + return False diff --git a/src/jarabe/journal/listview.py b/src/jarabe/journal/listview.py new file mode 100644 index 0000000..57836f2 --- /dev/null +++ b/src/jarabe/journal/listview.py @@ -0,0 +1,670 @@ +# Copyright (C) 2009, Tomeu Vizoso +# +# 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 logging +from gettext import gettext as _ +import time + +import gobject +import gtk +import hippo +import gconf +import pango + +from sugar.graphics import style +from sugar.graphics.icon import CanvasIcon, Icon, CellRendererIcon +from sugar.graphics.xocolor import XoColor +from sugar import util + +from jarabe.journal.listmodel import ListModel +from jarabe.journal.palettes import ObjectPalette, BuddyPalette +from jarabe.journal import model +from jarabe.journal import misc + + +UPDATE_INTERVAL = 300 + + +class TreeView(gtk.TreeView): + __gtype_name__ = 'JournalTreeView' + + def __init__(self): + gtk.TreeView.__init__(self) + self.set_headers_visible(False) + self.set_enable_search(False) + + def do_size_request(self, requisition): + # HACK: We tell the model that the view is just resizing so it can + # avoid hitting both D-Bus and disk. + tree_model = self.get_model() + if tree_model is not None: + tree_model.view_is_resizing = True + try: + gtk.TreeView.do_size_request(self, requisition) + finally: + if tree_model is not None: + tree_model.view_is_resizing = False + + +class BaseListView(gtk.Bin): + __gtype_name__ = 'JournalBaseListView' + + __gsignals__ = { + 'clear-clicked': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, ([])), + } + + def __init__(self): + self._query = {} + self._model = None + self._progress_bar = None + self._last_progress_bar_pulse = None + self._scroll_position = 0. + + gobject.GObject.__init__(self) + + self.connect('map', self.__map_cb) + self.connect('unrealize', self.__unrealize_cb) + self.connect('destroy', self.__destroy_cb) + + self._scrolled_window = gtk.ScrolledWindow() + self._scrolled_window.set_policy(gtk.POLICY_NEVER, + gtk.POLICY_AUTOMATIC) + self.add(self._scrolled_window) + self._scrolled_window.show() + + self.tree_view = TreeView() + selection = self.tree_view.get_selection() + selection.set_mode(gtk.SELECTION_NONE) + self.tree_view.props.fixed_height_mode = True + self.tree_view.modify_base(gtk.STATE_NORMAL, + style.COLOR_WHITE.get_gdk_color()) + self._scrolled_window.add(self.tree_view) + self.tree_view.show() + + self.cell_title = None + self.cell_icon = None + self._title_column = None + self.sort_column = None + self._add_columns() + + self.tree_view.enable_model_drag_source(gtk.gdk.BUTTON1_MASK, + [('text/uri-list', 0, 0), + ('journal-object-id', 0, 0)], + gtk.gdk.ACTION_COPY) + + # Auto-update stuff + self._fully_obscured = True + self._dirty = False + self._refresh_idle_handler = None + self._update_dates_timer = None + + model.created.connect(self.__model_created_cb) + model.updated.connect(self.__model_updated_cb) + model.deleted.connect(self.__model_deleted_cb) + + def __model_created_cb(self, sender, signal, object_id): + if self._is_new_item_visible(object_id): + self._set_dirty() + + def __model_updated_cb(self, sender, signal, object_id): + if self._is_new_item_visible(object_id): + self._set_dirty() + + def __model_deleted_cb(self, sender, signal, object_id): + if self._is_new_item_visible(object_id): + self._set_dirty() + + def _is_new_item_visible(self, object_id): + """Check if the created item is part of the currently selected view""" + if self._query['mountpoints'] == ['/']: + return not object_id.startswith('/') + else: + return object_id.startswith(self._query['mountpoints'][0]) + + def _add_columns(self): + cell_favorite = CellRendererFavorite(self.tree_view) + cell_favorite.connect('clicked', self.__favorite_clicked_cb) + + column = gtk.TreeViewColumn() + column.props.sizing = gtk.TREE_VIEW_COLUMN_FIXED + column.props.fixed_width = cell_favorite.props.width + column.pack_start(cell_favorite) + column.set_cell_data_func(cell_favorite, self.__favorite_set_data_cb) + self.tree_view.append_column(column) + + self.cell_icon = CellRendererActivityIcon(self.tree_view) + + column = gtk.TreeViewColumn() + column.props.sizing = gtk.TREE_VIEW_COLUMN_FIXED + column.props.fixed_width = self.cell_icon.props.width + column.pack_start(self.cell_icon) + column.add_attribute(self.cell_icon, 'file-name', + ListModel.COLUMN_ICON) + column.add_attribute(self.cell_icon, 'xo-color', + ListModel.COLUMN_ICON_COLOR) + self.tree_view.append_column(column) + + self.cell_title = gtk.CellRendererText() + self.cell_title.props.ellipsize = pango.ELLIPSIZE_MIDDLE + self.cell_title.props.ellipsize_set = True + + self._title_column = gtk.TreeViewColumn() + self._title_column.props.sizing = gtk.TREE_VIEW_COLUMN_FIXED + self._title_column.props.expand = True + self._title_column.props.clickable = True + self._title_column.pack_start(self.cell_title) + self._title_column.add_attribute(self.cell_title, 'markup', + ListModel.COLUMN_TITLE) + self.tree_view.append_column(self._title_column) + + buddies_column = gtk.TreeViewColumn() + buddies_column.props.sizing = gtk.TREE_VIEW_COLUMN_FIXED + self.tree_view.append_column(buddies_column) + + for column_index in [ListModel.COLUMN_BUDDY_1, + ListModel.COLUMN_BUDDY_2, + ListModel.COLUMN_BUDDY_3]: + cell_icon = CellRendererBuddy(self.tree_view, + column_index=column_index) + buddies_column.pack_start(cell_icon) + buddies_column.props.fixed_width += cell_icon.props.width + buddies_column.add_attribute(cell_icon, 'buddy', column_index) + buddies_column.set_cell_data_func(cell_icon, + self.__buddies_set_data_cb) + + cell_progress = gtk.CellRendererProgress() + cell_progress.props.ypad = style.GRID_CELL_SIZE / 4 + buddies_column.pack_start(cell_progress) + buddies_column.add_attribute(cell_progress, 'value', + ListModel.COLUMN_PROGRESS) + buddies_column.set_cell_data_func(cell_progress, + self.__progress_data_cb) + + cell_text = gtk.CellRendererText() + cell_text.props.xalign = 1 + + # Measure the required width for a date in the form of "10 hours, 10 + # minutes ago" + timestamp = time.time() - 10 * 60 - 10 * 60 * 60 + date = util.timestamp_to_elapsed_string(timestamp) + date_width = self._get_width_for_string(date) + + self.sort_column = gtk.TreeViewColumn() + self.sort_column.props.sizing = gtk.TREE_VIEW_COLUMN_FIXED + self.sort_column.props.fixed_width = date_width + self.sort_column.set_alignment(1) + self.sort_column.props.resizable = True + self.sort_column.props.clickable = True + self.sort_column.pack_start(cell_text) + self.sort_column.add_attribute(cell_text, 'text', + ListModel.COLUMN_TIMESTAMP) + self.tree_view.append_column(self.sort_column) + + def _get_width_for_string(self, text): + # Add some extra margin + text = text + 'aaaaa' + + widget = gtk.Label('') + context = widget.get_pango_context() + layout = pango.Layout(context) + layout.set_text(text) + width, height_ = layout.get_size() + return pango.PIXELS(width) + + def do_size_allocate(self, allocation): + self.allocation = allocation + self.child.size_allocate(allocation) + + def do_size_request(self, requisition): + requisition.width, requisition.height = self.child.size_request() + + def __destroy_cb(self, widget): + if self._model is not None: + self._model.stop() + + def __buddies_set_data_cb(self, column, cell, tree_model, tree_iter): + progress = tree_model[tree_iter][ListModel.COLUMN_PROGRESS] + cell.props.visible = progress >= 100 + + def __progress_data_cb(self, column, cell, tree_model, tree_iter): + progress = tree_model[tree_iter][ListModel.COLUMN_PROGRESS] + cell.props.visible = progress < 100 + + def __favorite_set_data_cb(self, column, cell, tree_model, tree_iter): + favorite = tree_model[tree_iter][ListModel.COLUMN_FAVORITE] + if favorite: + client = gconf.client_get_default() + color = XoColor(client.get_string('/desktop/sugar/user/color')) + cell.props.xo_color = color + else: + cell.props.xo_color = None + + def __favorite_clicked_cb(self, cell, path): + row = self._model[path] + metadata = model.get(row[ListModel.COLUMN_UID]) + if not model.is_editable(metadata): + return + if metadata.get('keep', 0) == '1': + metadata['keep'] = '0' + else: + metadata['keep'] = '1' + model.write(metadata, update_mtime=False) + + def update_with_query(self, query_dict): + logging.debug('ListView.update_with_query') + if 'order_by' not in query_dict: + query_dict['order_by'] = ['+timestamp'] + if query_dict['order_by'] != self._query.get('order_by'): + property_ = query_dict['order_by'][0][1:] + cell_text = self.sort_column.get_cell_renderers()[0] + self.sort_column.set_attributes(cell_text, + text=getattr(ListModel, 'COLUMN_' + property_.upper(), + ListModel.COLUMN_TIMESTAMP)) + self._query = query_dict + + self.refresh() + + def refresh(self): + logging.debug('ListView.refresh query %r', self._query) + self._stop_progress_bar() + + if self._model is not None: + self._model.stop() + self._dirty = False + + self._model = ListModel(self._query) + self._model.connect('ready', self.__model_ready_cb) + self._model.connect('progress', self.__model_progress_cb) + self._model.setup() + + def __model_ready_cb(self, tree_model): + self._stop_progress_bar() + + self._scroll_position = self.tree_view.props.vadjustment.props.value + logging.debug('ListView.__model_ready_cb %r', self._scroll_position) + + if self.tree_view.window is not None: + # prevent glitches while later vadjustment setting, see #1235 + self.tree_view.get_bin_window().hide() + + # Cannot set it up earlier because will try to access the model + # and it needs to be ready. + self.tree_view.set_model(self._model) + + self.tree_view.props.vadjustment.props.value = self._scroll_position + self.tree_view.props.vadjustment.value_changed() + + if self.tree_view.window is not None: + # prevent glitches while later vadjustment setting, see #1235 + self.tree_view.get_bin_window().show() + + if len(tree_model) == 0: + if self._is_query_empty(): + if self._query['mountpoints'] == ['/']: + self._show_message(_('Your Journal is empty')) + elif self._query['mountpoints'] == \ + [model.get_documents_path()]: + self._show_message(_('Your documents folder is empty')) + else: + self._show_message(_('The device is empty')) + else: + self._show_message(_('No matching entries'), + show_clear_query=True) + else: + self._clear_message() + + def __map_cb(self, widget): + logging.debug('ListView.__map_cb %r', self._scroll_position) + self.tree_view.props.vadjustment.props.value = self._scroll_position + self.tree_view.props.vadjustment.value_changed() + + def __unrealize_cb(self, widget): + self._scroll_position = self.tree_view.props.vadjustment.props.value + logging.debug('ListView.__map_cb %r', self._scroll_position) + + def _is_query_empty(self): + # FIXME: This is a hack, we shouldn't have to update this every time + # a new search term is added. + return not (self._query.get('query') or self._query.get('mime_type') or + self._query.get('keep') or self._query.get('mtime') or + self._query.get('activity')) + + def __model_progress_cb(self, tree_model): + if self._progress_bar is None: + self._start_progress_bar() + + if time.time() - self._last_progress_bar_pulse > 0.05: + self._progress_bar.pulse() + self._last_progress_bar_pulse = time.time() + + def _start_progress_bar(self): + alignment = gtk.Alignment(xalign=0.5, yalign=0.5, xscale=0.5) + self.remove(self.child) + self.add(alignment) + alignment.show() + + self._progress_bar = gtk.ProgressBar() + self._progress_bar.props.pulse_step = 0.01 + self._last_progress_bar_pulse = time.time() + alignment.add(self._progress_bar) + self._progress_bar.show() + + def _stop_progress_bar(self): + if self._progress_bar is None: + return + self.remove(self.child) + self.add(self._scrolled_window) + self._progress_bar = None + + def _show_message(self, message, show_clear_query=False): + canvas = hippo.Canvas() + self.remove(self.child) + self.add(canvas) + canvas.show() + + box = hippo.CanvasBox(orientation=hippo.ORIENTATION_VERTICAL, + background_color=style.COLOR_WHITE.get_int(), + yalign=hippo.ALIGNMENT_CENTER, + spacing=style.DEFAULT_SPACING, + padding_bottom=style.GRID_CELL_SIZE) + canvas.set_root(box) + + icon = CanvasIcon(size=style.LARGE_ICON_SIZE, + icon_name='activity-journal', + stroke_color=style.COLOR_BUTTON_GREY.get_svg(), + fill_color=style.COLOR_TRANSPARENT.get_svg()) + box.append(icon) + + text = hippo.CanvasText(text=message, + xalign=hippo.ALIGNMENT_CENTER, + font_desc=style.FONT_BOLD.get_pango_desc(), + color=style.COLOR_BUTTON_GREY.get_int()) + box.append(text) + + if show_clear_query: + button = gtk.Button(label=_('Clear search')) + button.connect('clicked', self.__clear_button_clicked_cb) + button.props.image = Icon(icon_name='dialog-cancel', + icon_size=gtk.ICON_SIZE_BUTTON) + canvas_button = hippo.CanvasWidget(widget=button, + xalign=hippo.ALIGNMENT_CENTER) + box.append(canvas_button) + + def __clear_button_clicked_cb(self, button): + self.emit('clear-clicked') + + def _clear_message(self): + if self.child == self._scrolled_window: + return + self.remove(self.child) + self.add(self._scrolled_window) + self._scrolled_window.show() + + def update_dates(self): + if not self.tree_view.flags() & gtk.REALIZED: + return + visible_range = self.tree_view.get_visible_range() + if visible_range is None: + return + + logging.debug('ListView.update_dates') + + path, end_path = visible_range + tree_model = self.tree_view.get_model() + + while True: + x, y, width, height = self.tree_view.get_cell_area(path, + self.sort_column) + x, y = self.tree_view.convert_tree_to_widget_coords(x, y) + self.tree_view.queue_draw_area(x, y, width, height) + if path == end_path: + break + else: + next_iter = tree_model.iter_next(tree_model.get_iter(path)) + path = tree_model.get_path(next_iter) + + def _set_dirty(self): + if self._fully_obscured: + self._dirty = True + else: + self.refresh() + + def set_is_visible(self, visible): + if visible != self._fully_obscured: + return + + logging.debug('canvas_visibility_notify_event_cb %r', visible) + if visible: + self._fully_obscured = False + if self._dirty: + self.refresh() + if self._update_dates_timer is None: + logging.debug('Adding date updating timer') + self._update_dates_timer = \ + gobject.timeout_add_seconds(UPDATE_INTERVAL, + self.__update_dates_timer_cb) + else: + self._fully_obscured = True + if self._update_dates_timer is not None: + logging.debug('Remove date updating timer') + gobject.source_remove(self._update_dates_timer) + self._update_dates_timer = None + + def __update_dates_timer_cb(self): + self.update_dates() + return True + + +class ListView(BaseListView): + __gtype_name__ = 'JournalListView' + + __gsignals__ = { + 'detail-clicked': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, + ([object])), + 'volume-error': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, + ([str, str])), + } + + def __init__(self): + BaseListView.__init__(self) + self._is_dragging = False + + self.tree_view.connect('drag-begin', self.__drag_begin_cb) + self.tree_view.connect('button-release-event', + self.__button_release_event_cb) + + self.cell_title.connect('edited', self.__cell_title_edited_cb) + self.cell_title.connect('editing-canceled', self.__editing_canceled_cb) + + self.cell_icon.connect('clicked', self.__icon_clicked_cb) + self.cell_icon.connect('detail-clicked', self.__detail_clicked_cb) + self.cell_icon.connect('volume-error', self.__volume_error_cb) + + cell_detail = CellRendererDetail(self.tree_view) + cell_detail.connect('clicked', self.__detail_cell_clicked_cb) + + column = gtk.TreeViewColumn() + column.props.sizing = gtk.TREE_VIEW_COLUMN_FIXED + column.props.fixed_width = cell_detail.props.width + column.pack_start(cell_detail) + self.tree_view.append_column(column) + + def __drag_begin_cb(self, widget, drag_context): + self._is_dragging = True + + def __button_release_event_cb(self, tree_view, event): + try: + if self._is_dragging: + return + finally: + self._is_dragging = False + + pos = tree_view.get_path_at_pos(int(event.x), int(event.y)) + if pos is None: + return + + path, column, x_, y_ = pos + if column != self._title_column: + return + + row = self.tree_view.get_model()[path] + metadata = model.get(row[ListModel.COLUMN_UID]) + self.cell_title.props.editable = model.is_editable(metadata) + + tree_view.set_cursor_on_cell(path, column, start_editing=True) + + def __detail_cell_clicked_cb(self, cell, path): + row = self.tree_view.get_model()[path] + self.emit('detail-clicked', row[ListModel.COLUMN_UID]) + + def __detail_clicked_cb(self, cell, uid): + self.emit('detail-clicked', uid) + + def __volume_error_cb(self, cell, message, severity): + self.emit('volume-error', message, severity) + + def __icon_clicked_cb(self, cell, path): + row = self.tree_view.get_model()[path] + metadata = model.get(row[ListModel.COLUMN_UID]) + misc.resume(metadata) + + def __cell_title_edited_cb(self, cell, path, new_text): + row = self._model[path] + metadata = model.get(row[ListModel.COLUMN_UID]) + metadata['title'] = new_text + model.write(metadata, update_mtime=False) + self.cell_title.props.editable = False + + def __editing_canceled_cb(self, cell): + self.cell_title.props.editable = False + + +class CellRendererFavorite(CellRendererIcon): + __gtype_name__ = 'JournalCellRendererFavorite' + + def __init__(self, tree_view): + CellRendererIcon.__init__(self, tree_view) + + self.props.width = style.GRID_CELL_SIZE + self.props.height = style.GRID_CELL_SIZE + self.props.size = style.SMALL_ICON_SIZE + self.props.icon_name = 'emblem-favorite' + self.props.mode = gtk.CELL_RENDERER_MODE_ACTIVATABLE + client = gconf.client_get_default() + prelit_color = XoColor(client.get_string('/desktop/sugar/user/color')) + self.props.prelit_stroke_color = prelit_color.get_stroke_color() + self.props.prelit_fill_color = prelit_color.get_fill_color() + + +class CellRendererDetail(CellRendererIcon): + __gtype_name__ = 'JournalCellRendererDetail' + + def __init__(self, tree_view): + CellRendererIcon.__init__(self, tree_view) + + self.props.width = style.GRID_CELL_SIZE + self.props.height = style.GRID_CELL_SIZE + self.props.size = style.SMALL_ICON_SIZE + self.props.icon_name = 'go-right' + self.props.mode = gtk.CELL_RENDERER_MODE_ACTIVATABLE + self.props.stroke_color = style.COLOR_TRANSPARENT.get_svg() + self.props.fill_color = style.COLOR_BUTTON_GREY.get_svg() + self.props.prelit_stroke_color = style.COLOR_TRANSPARENT.get_svg() + self.props.prelit_fill_color = style.COLOR_BLACK.get_svg() + + +class CellRendererActivityIcon(CellRendererIcon): + __gtype_name__ = 'JournalCellRendererActivityIcon' + + __gsignals__ = { + 'detail-clicked': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, + ([str])), + 'volume-error': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, + ([str, str])), + } + + def __init__(self, tree_view): + self._show_palette = True + + CellRendererIcon.__init__(self, tree_view) + + self.props.width = style.GRID_CELL_SIZE + self.props.height = style.GRID_CELL_SIZE + self.props.size = style.STANDARD_ICON_SIZE + self.props.mode = gtk.CELL_RENDERER_MODE_ACTIVATABLE + + self.tree_view = tree_view + + def create_palette(self): + if not self._show_palette: + return None + + tree_model = self.tree_view.get_model() + metadata = tree_model.get_metadata(self.props.palette_invoker.path) + + palette = ObjectPalette(metadata, detail=True) + palette.connect('detail-clicked', + self.__detail_clicked_cb) + palette.connect('volume-error', + self.__volume_error_cb) + return palette + + def __detail_clicked_cb(self, palette, uid): + self.emit('detail-clicked', uid) + + def __volume_error_cb(self, palette, message, severity): + self.emit('volume-error', message, severity) + + def set_show_palette(self, show_palette): + self._show_palette = show_palette + + show_palette = gobject.property(type=bool, default=True, + setter=set_show_palette) + + +class CellRendererBuddy(CellRendererIcon): + __gtype_name__ = 'JournalCellRendererBuddy' + + def __init__(self, tree_view, column_index): + CellRendererIcon.__init__(self, tree_view) + + self.props.width = style.STANDARD_ICON_SIZE + self.props.height = style.STANDARD_ICON_SIZE + self.props.size = style.STANDARD_ICON_SIZE + self.props.mode = gtk.CELL_RENDERER_MODE_ACTIVATABLE + + self.tree_view = tree_view + self._model_column_index = column_index + + def create_palette(self): + tree_model = self.tree_view.get_model() + row = tree_model[self.props.palette_invoker.path] + + if row[self._model_column_index] is not None: + nick, xo_color = row[self._model_column_index] + return BuddyPalette((nick, xo_color.to_string())) + else: + return None + + def set_buddy(self, buddy): + if buddy is None: + self.props.icon_name = None + else: + nick_, xo_color = buddy + self.props.icon_name = 'computer-xo' + self.props.xo_color = xo_color + + buddy = gobject.property(type=object, setter=set_buddy) diff --git a/src/jarabe/journal/misc.py b/src/jarabe/journal/misc.py new file mode 100644 index 0000000..1431d5f --- /dev/null +++ b/src/jarabe/journal/misc.py @@ -0,0 +1,315 @@ +# Copyright (C) 2007, One Laptop Per Child +# +# 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 logging +import time +import os +from gettext import gettext as _ + +import gio +import gconf +import gtk + +from sugar.activity import activityfactory +from sugar.activity.activityhandle import ActivityHandle +from sugar.graphics.icon import get_icon_file_name +from sugar.graphics.xocolor import XoColor +from sugar.graphics.alert import ConfirmationAlert +from sugar import mime +from sugar.bundle.activitybundle import ActivityBundle +from sugar.bundle.bundle import AlreadyInstalledException +from sugar.bundle.contentbundle import ContentBundle +from sugar import util + +from jarabe.view import launcher +from jarabe.model import bundleregistry, shell +from jarabe.journal.journalentrybundle import JournalEntryBundle +from jarabe.journal import model +from jarabe.journal import journalwindow + + +def _get_icon_for_mime(mime_type): + generic_types = mime.get_all_generic_types() + for generic_type in generic_types: + if mime_type in generic_type.mime_types: + file_name = get_icon_file_name(generic_type.icon) + if file_name is not None: + return file_name + + icons = gio.content_type_get_icon(mime_type) + logging.debug('icons for this file: %r', icons.props.names) + for icon_name in icons.props.names: + file_name = get_icon_file_name(icon_name) + if file_name is not None: + return file_name + + +def get_icon_name(metadata): + file_name = None + + bundle_id = metadata.get('activity', '') + if not bundle_id: + bundle_id = metadata.get('bundle_id', '') + + if bundle_id: + activity_info = bundleregistry.get_registry().get_bundle(bundle_id) + if activity_info: + file_name = activity_info.get_icon() + + if file_name is None and is_activity_bundle(metadata): + file_path = model.get_file(metadata['uid']) + if file_path is not None and os.path.exists(file_path): + try: + bundle = ActivityBundle(file_path) + file_name = bundle.get_icon() + except Exception: + logging.exception('Could not read bundle') + + if file_name is None: + file_name = _get_icon_for_mime(metadata.get('mime_type', '')) + + if file_name is None: + file_name = get_icon_file_name('application-octet-stream') + + return file_name + + +def get_date(metadata): + """ Convert from a string in iso format to a more human-like format. """ + if 'timestamp' in metadata: + try: + timestamp = float(metadata['timestamp']) + except (TypeError, ValueError): + logging.warning('Invalid timestamp: %r', metadata['timestamp']) + else: + return util.timestamp_to_elapsed_string(timestamp) + + if 'mtime' in metadata: + try: + ti = time.strptime(metadata['mtime'], '%Y-%m-%dT%H:%M:%S') + except (TypeError, ValueError): + logging.warning('Invalid mtime: %r', metadata['mtime']) + else: + return util.timestamp_to_elapsed_string(time.mktime(ti)) + + return _('No date') + + +def get_bundle(metadata): + try: + if is_activity_bundle(metadata): + file_path = model.get_file(metadata['uid']) + if not os.path.exists(file_path): + logging.warning('Invalid path: %r', file_path) + return None + return ActivityBundle(file_path) + + elif is_content_bundle(metadata): + file_path = model.get_file(metadata['uid']) + if not os.path.exists(file_path): + logging.warning('Invalid path: %r', file_path) + return None + return ContentBundle(file_path) + + elif is_journal_bundle(metadata): + file_path = model.get_file(metadata['uid']) + if not os.path.exists(file_path): + logging.warning('Invalid path: %r', file_path) + return None + return JournalEntryBundle(file_path) + else: + return None + except Exception: + logging.exception('Incorrect bundle') + return None + + +def _get_activities_for_mime(mime_type): + registry = bundleregistry.get_registry() + result = registry.get_activities_for_type(mime_type) + if not result: + for parent_mime in mime.get_mime_parents(mime_type): + for activity in registry.get_activities_for_type(parent_mime): + if activity not in result: + result.append(activity) + return result + + +def get_activities(metadata): + activities = [] + + bundle_id = metadata.get('activity', '') + if bundle_id: + activity_info = bundleregistry.get_registry().get_bundle(bundle_id) + if activity_info: + activities.append(activity_info) + + mime_type = metadata.get('mime_type', '') + if mime_type: + activities_info = _get_activities_for_mime(mime_type) + for activity_info in activities_info: + if activity_info not in activities: + activities.append(activity_info) + + return activities + + +def resume(metadata, bundle_id=None): + registry = bundleregistry.get_registry() + + if is_activity_bundle(metadata) and bundle_id is None: + + logging.debug('Creating activity bundle') + + file_path = model.get_file(metadata['uid']) + bundle = ActivityBundle(file_path) + if not registry.is_installed(bundle): + logging.debug('Installing activity bundle') + try: + registry.install(bundle) + except AlreadyInstalledException: + _downgrade_option_alert(bundle) + return + else: + logging.debug('Upgrading activity bundle') + registry.upgrade(bundle) + + _launch_bundle(bundle) + + elif is_content_bundle(metadata) and bundle_id is None: + + logging.debug('Creating content bundle') + + file_path = model.get_file(metadata['uid']) + bundle = ContentBundle(file_path) + if not bundle.is_installed(): + logging.debug('Installing content bundle') + bundle.install() + + activities = _get_activities_for_mime('text/html') + if len(activities) == 0: + logging.warning('No activity can open HTML content bundles') + return + + uri = bundle.get_start_uri() + logging.debug('activityfactory.creating with uri %s', uri) + + activity_bundle = registry.get_bundle(activities[0].get_bundle_id()) + launch(activity_bundle, uri=uri) + else: + activity_id = metadata.get('activity_id', '') + + if bundle_id is None: + activities = get_activities(metadata) + if not activities: + logging.warning('No activity can open this object, %s.', + metadata.get('mime_type', None)) + return + bundle_id = activities[0].get_bundle_id() + + bundle = registry.get_bundle(bundle_id) + + if metadata.get('mountpoint', '/') == '/': + object_id = metadata['uid'] + else: + object_id = model.copy(metadata, '/') + + launch(bundle, activity_id=activity_id, object_id=object_id, + color=get_icon_color(metadata)) + + +def _launch_bundle(bundle): + registry = bundleregistry.get_registry() + logging.debug('activityfactory.creating bundle with id %r', + bundle.get_bundle_id()) + installed_bundle = registry.get_bundle(bundle.get_bundle_id()) + if installed_bundle: + launch(installed_bundle) + else: + logging.error('Bundle %r is not installed.', + bundle.get_bundle_id()) + + +def launch(bundle, activity_id=None, object_id=None, uri=None, color=None, + invited=False): + if activity_id is None or not activity_id: + activity_id = activityfactory.create_activity_id() + + logging.debug('launch bundle_id=%s activity_id=%s object_id=%s uri=%s', + bundle.get_bundle_id(), activity_id, object_id, uri) + + shell_model = shell.get_model() + activity = shell_model.get_activity_by_id(activity_id) + if activity is not None: + logging.debug('re-launch %r', activity.get_window()) + activity.get_window().activate(gtk.get_current_event_time()) + return + + if color is None: + client = gconf.client_get_default() + color = XoColor(client.get_string('/desktop/sugar/user/color')) + + launcher.add_launcher(activity_id, bundle.get_icon(), color) + activity_handle = ActivityHandle(activity_id=activity_id, + object_id=object_id, uri=uri, invited=invited) + activityfactory.create(bundle, activity_handle) + + +def _downgrade_option_alert(bundle): + alert = ConfirmationAlert() + alert.props.title = _('Older Version Of %s Activity') % (bundle.get_name()) + alert.props.msg = _('Do you want to downgrade to version %s') % \ + bundle.get_activity_version() + alert.connect('response', _downgrade_alert_response_cb, bundle) + journalwindow.get_journal_window().add_alert(alert) + alert.show() + + +def _downgrade_alert_response_cb(alert, response_id, bundle): + if response_id is gtk.RESPONSE_OK: + journalwindow.get_journal_window().remove_alert(alert) + registry = bundleregistry.get_registry() + registry.install(bundle, force_downgrade=True) + _launch_bundle(bundle) + elif response_id is gtk.RESPONSE_CANCEL: + journalwindow.get_journal_window().remove_alert(alert) + + +def is_activity_bundle(metadata): + mime_type = metadata.get('mime_type', '') + return mime_type == ActivityBundle.MIME_TYPE or \ + mime_type == ActivityBundle.DEPRECATED_MIME_TYPE + + +def is_content_bundle(metadata): + return metadata.get('mime_type', '') == ContentBundle.MIME_TYPE + + +def is_journal_bundle(metadata): + return metadata.get('mime_type', '') == JournalEntryBundle.MIME_TYPE + + +def is_bundle(metadata): + return is_activity_bundle(metadata) or is_content_bundle(metadata) or \ + is_journal_bundle(metadata) + + +def get_icon_color(metadata): + if metadata is None or not 'icon-color' in metadata: + client = gconf.client_get_default() + return XoColor(client.get_string('/desktop/sugar/user/color')) + else: + return XoColor(metadata['icon-color']) diff --git a/src/jarabe/journal/modalalert.py b/src/jarabe/journal/modalalert.py new file mode 100644 index 0000000..6880941 --- /dev/null +++ b/src/jarabe/journal/modalalert.py @@ -0,0 +1,96 @@ +# Copyright (C) 2008 One Laptop Per Child +# +# 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 gtk +from gettext import gettext as _ +import gconf + +from sugar.graphics.icon import Icon +from sugar.graphics import style +from sugar.graphics.xocolor import XoColor + + +class ModalAlert(gtk.Window): + + __gtype_name__ = 'SugarModalAlert' + + def __init__(self): + gtk.Window.__init__(self) + + self.set_border_width(style.LINE_WIDTH) + offset = style.GRID_CELL_SIZE + width = gtk.gdk.screen_width() - offset * 2 + height = gtk.gdk.screen_height() - offset * 2 + 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._main_view = gtk.EventBox() + self._vbox = gtk.VBox() + self._vbox.set_spacing(style.DEFAULT_SPACING) + self._vbox.set_border_width(style.GRID_CELL_SIZE * 2) + self._main_view.modify_bg(gtk.STATE_NORMAL, + style.COLOR_BLACK.get_gdk_color()) + self._main_view.add(self._vbox) + self._vbox.show() + + client = gconf.client_get_default() + color = XoColor(client.get_string('/desktop/sugar/user/color')) + + icon = Icon(icon_name='activity-journal', + pixel_size=style.XLARGE_ICON_SIZE, + xo_color=color) + self._vbox.pack_start(icon, False) + icon.show() + + self._title = gtk.Label() + self._title.modify_fg(gtk.STATE_NORMAL, + style.COLOR_WHITE.get_gdk_color()) + self._title.set_markup('<b>%s</b>' % _('Your Journal is full')) + self._vbox.pack_start(self._title, False) + self._title.show() + + self._message = gtk.Label(_('Please delete some old Journal' + ' entries to make space for new ones.')) + self._message.modify_fg(gtk.STATE_NORMAL, + style.COLOR_WHITE.get_gdk_color()) + self._vbox.pack_start(self._message, False) + self._message.show() + + alignment = gtk.Alignment(xalign=0.5, yalign=0.5) + self._vbox.pack_start(alignment, expand=False) + alignment.show() + + self._show_journal = gtk.Button() + self._show_journal.set_label(_('Show Journal')) + alignment.add(self._show_journal) + self._show_journal.show() + self._show_journal.connect('clicked', self.__show_journal_cb) + + self.add(self._main_view) + self._main_view.show() + + self.connect('realize', self.__realize_cb) + + def __realize_cb(self, widget): + self.window.set_type_hint(gtk.gdk.WINDOW_TYPE_HINT_DIALOG) + self.window.set_accept_focus(True) + + def __show_journal_cb(self, button): + """The opener will listen on the destroy signal""" + self.destroy() diff --git a/src/jarabe/journal/model.py b/src/jarabe/journal/model.py new file mode 100644 index 0000000..5285a7c --- /dev/null +++ b/src/jarabe/journal/model.py @@ -0,0 +1,818 @@ +# Copyright (C) 2007-2011, One Laptop per Child +# +# 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 logging +import os +import errno +import subprocess +from datetime import datetime +import time +import shutil +import tempfile +from stat import S_IFLNK, S_IFMT, S_IFDIR, S_IFREG +import re +from operator import itemgetter +import simplejson +from gettext import gettext as _ + +import gobject +import dbus +import gio +import gconf + +from sugar import dispatch +from sugar import mime +from sugar import util + + +DS_DBUS_SERVICE = 'org.laptop.sugar.DataStore' +DS_DBUS_INTERFACE = 'org.laptop.sugar.DataStore' +DS_DBUS_PATH = '/org/laptop/sugar/DataStore' + +# Properties the journal cares about. +PROPERTIES = ['activity', 'activity_id', 'buddies', 'bundle_id', + 'creation_time', 'filesize', 'icon-color', 'keep', 'mime_type', + 'mountpoint', 'mtime', 'progress', 'timestamp', 'title', 'uid'] + +MIN_PAGES_TO_CACHE = 3 +MAX_PAGES_TO_CACHE = 5 + +JOURNAL_METADATA_DIR = '.Sugar-Metadata' + +_datastore = None +created = dispatch.Signal() +updated = dispatch.Signal() +deleted = dispatch.Signal() + + +class _Cache(object): + + __gtype_name__ = 'model_Cache' + + def __init__(self, entries=None): + self._array = [] + if entries is not None: + self.append_all(entries) + + def prepend_all(self, entries): + self._array[0:0] = entries + + def append_all(self, entries): + self._array += entries + + def __len__(self): + return len(self._array) + + def __getitem__(self, key): + return self._array[key] + + def __delitem__(self, key): + del self._array[key] + + +class BaseResultSet(object): + """Encapsulates the result of a query + """ + + def __init__(self, query, page_size): + self._total_count = -1 + self._position = -1 + self._query = query + self._page_size = page_size + + self._offset = 0 + self._cache = _Cache() + + self.ready = dispatch.Signal() + self.progress = dispatch.Signal() + + def setup(self): + self.ready.send(self) + + def stop(self): + pass + + def get_length(self): + if self._total_count == -1: + query = self._query.copy() + query['limit'] = self._page_size * MIN_PAGES_TO_CACHE + entries, self._total_count = self.find(query) + self._cache.append_all(entries) + self._offset = 0 + return self._total_count + + length = property(get_length) + + def find(self, query): + raise NotImplementedError() + + def seek(self, position): + self._position = position + + def read(self): + if self._position == -1: + self.seek(0) + + if self._position < self._offset: + remaining_forward_entries = 0 + else: + remaining_forward_entries = self._offset + len(self._cache) - \ + self._position + + if self._position > self._offset + len(self._cache): + remaining_backwards_entries = 0 + else: + remaining_backwards_entries = self._position - self._offset + + last_cached_entry = self._offset + len(self._cache) + + if remaining_forward_entries <= 0 and remaining_backwards_entries <= 0: + + # Total cache miss: remake it + limit = self._page_size * MIN_PAGES_TO_CACHE + offset = max(0, self._position - limit / 2) + logging.debug('remaking cache, offset: %r limit: %r', offset, + limit) + query = self._query.copy() + query['limit'] = limit + query['offset'] = offset + entries, self._total_count = self.find(query) + + del self._cache[:] + self._cache.append_all(entries) + self._offset = offset + + elif (remaining_forward_entries <= 0 and + remaining_backwards_entries > 0): + + # Add one page to the end of cache + logging.debug('appending one more page, offset: %r', + last_cached_entry) + query = self._query.copy() + query['limit'] = self._page_size + query['offset'] = last_cached_entry + entries, self._total_count = self.find(query) + + # update cache + self._cache.append_all(entries) + + # apply the cache limit + cache_limit = self._page_size * MAX_PAGES_TO_CACHE + objects_excess = len(self._cache) - cache_limit + if objects_excess > 0: + self._offset += objects_excess + del self._cache[:objects_excess] + + elif remaining_forward_entries > 0 and \ + remaining_backwards_entries <= 0 and self._offset > 0: + + # Add one page to the beginning of cache + limit = min(self._offset, self._page_size) + self._offset = max(0, self._offset - limit) + + logging.debug('prepending one more page, offset: %r limit: %r', + self._offset, limit) + query = self._query.copy() + query['limit'] = limit + query['offset'] = self._offset + entries, self._total_count = self.find(query) + + # update cache + self._cache.prepend_all(entries) + + # apply the cache limit + cache_limit = self._page_size * MAX_PAGES_TO_CACHE + objects_excess = len(self._cache) - cache_limit + if objects_excess > 0: + del self._cache[-objects_excess:] + + return self._cache[self._position - self._offset] + + +class DatastoreResultSet(BaseResultSet): + """Encapsulates the result of a query on the datastore + """ + def __init__(self, query, page_size): + + if query.get('query', '') and not query['query'].startswith('"'): + query_text = '' + words = query['query'].split(' ') + for word in words: + if word: + if query_text: + query_text += ' ' + query_text += word + '*' + + query['query'] = query_text + + BaseResultSet.__init__(self, query, page_size) + + def find(self, query): + entries, total_count = _get_datastore().find(query, PROPERTIES, + byte_arrays=True) + + for entry in entries: + entry['mountpoint'] = '/' + + return entries, total_count + + +class InplaceResultSet(BaseResultSet): + """Encapsulates the result of a query on a mount point + """ + def __init__(self, query, page_size, mount_point): + BaseResultSet.__init__(self, query, page_size) + self._mount_point = mount_point + self._file_list = None + self._pending_directories = [] + self._visited_directories = [] + self._pending_files = [] + self._stopped = False + + query_text = query.get('query', '') + if query_text.startswith('"') and query_text.endswith('"'): + self._regex = re.compile('*%s*' % query_text.strip(['"'])) + elif query_text: + expression = '' + for word in query_text.split(' '): + expression += '(?=.*%s.*)' % word + self._regex = re.compile(expression, re.IGNORECASE) + else: + self._regex = None + + if query.get('timestamp', ''): + self._date_start = int(query['timestamp']['start']) + self._date_end = int(query['timestamp']['end']) + else: + self._date_start = None + self._date_end = None + + self._mime_types = query.get('mime_type', []) + + self._sort = query.get('order_by', ['+timestamp'])[0] + + def setup(self): + self._file_list = [] + self._pending_directories = [self._mount_point] + self._visited_directories = [] + self._pending_files = [] + gobject.idle_add(self._scan) + + def stop(self): + self._stopped = True + + def setup_ready(self): + if self._sort[1:] == 'filesize': + keygetter = itemgetter(3) + else: + # timestamp + keygetter = itemgetter(2) + self._file_list.sort(lambda a, b: cmp(b, a), + key=keygetter, + reverse=(self._sort[0] == '-')) + self.ready.send(self) + + def find(self, query): + if self._file_list is None: + raise ValueError('Need to call setup() first') + + if self._stopped: + raise ValueError('InplaceResultSet already stopped') + + t = time.time() + + offset = int(query.get('offset', 0)) + limit = int(query.get('limit', len(self._file_list))) + total_count = len(self._file_list) + + files = self._file_list[offset:offset + limit] + + entries = [] + for file_path, stat, mtime_, size_, metadata in files: + if metadata is None: + metadata = _get_file_metadata(file_path, stat) + metadata['mountpoint'] = self._mount_point + entries.append(metadata) + + logging.debug('InplaceResultSet.find took %f s.', time.time() - t) + + return entries, total_count + + def _scan(self): + if self._stopped: + return False + + self.progress.send(self) + + if self._pending_files: + self._scan_a_file() + return True + + if self._pending_directories: + self._scan_a_directory() + return True + + self.setup_ready() + self._visited_directories = [] + return False + + def _scan_a_file(self): + full_path = self._pending_files.pop(0) + metadata = None + + try: + stat = os.lstat(full_path) + except OSError, e: + if e.errno != errno.ENOENT: + logging.exception( + 'Error reading metadata of file %r', full_path) + return + + if S_IFMT(stat.st_mode) == S_IFLNK: + try: + link = os.readlink(full_path) + except OSError, e: + logging.exception( + 'Error reading target of link %r', full_path) + return + + if not os.path.abspath(link).startswith(self._mount_point): + return + + try: + stat = os.stat(full_path) + + except OSError, e: + if e.errno != errno.ENOENT: + logging.exception( + 'Error reading metadata of linked file %r', full_path) + return + + if S_IFMT(stat.st_mode) == S_IFDIR: + id_tuple = stat.st_ino, stat.st_dev + if not id_tuple in self._visited_directories: + self._visited_directories.append(id_tuple) + self._pending_directories.append(full_path) + return + + if S_IFMT(stat.st_mode) != S_IFREG: + return + + if self._regex is not None and \ + not self._regex.match(full_path): + metadata = _get_file_metadata(full_path, stat, + fetch_preview=False) + if not metadata: + return + add_to_list = False + for f in ['fulltext', 'title', + 'description', 'tags']: + if f in metadata and \ + self._regex.match(metadata[f]): + add_to_list = True + break + if not add_to_list: + return + + if self._date_start is not None and stat.st_mtime < self._date_start: + return + + if self._date_end is not None and stat.st_mtime > self._date_end: + return + + if self._mime_types: + mime_type = gio.content_type_guess(filename=full_path) + if mime_type not in self._mime_types: + return + + file_info = (full_path, stat, int(stat.st_mtime), stat.st_size, + metadata) + self._file_list.append(file_info) + + return + + def _scan_a_directory(self): + dir_path = self._pending_directories.pop(0) + + try: + entries = os.listdir(dir_path) + except OSError, e: + if e.errno != errno.EACCES: + logging.exception('Error reading directory %r', dir_path) + return + + for entry in entries: + if entry.startswith('.'): + continue + self._pending_files.append(dir_path + '/' + entry) + return + + +def _get_file_metadata(path, stat, fetch_preview=True): + """Return the metadata from the corresponding file. + + Reads the metadata stored in the json file or create the + metadata based on the file properties. + + """ + filename = os.path.basename(path) + dir_path = os.path.dirname(path) + metadata = _get_file_metadata_from_json(dir_path, filename, fetch_preview) + if metadata: + if 'filesize' not in metadata: + metadata['filesize'] = stat.st_size + return metadata + + return {'uid': path, + 'title': os.path.basename(path), + 'timestamp': stat.st_mtime, + 'filesize': stat.st_size, + 'mime_type': gio.content_type_guess(filename=path), + 'activity': '', + 'activity_id': '', + 'icon-color': '#000000,#ffffff', + 'description': path} + + +def _get_file_metadata_from_json(dir_path, filename, fetch_preview): + """Read the metadata from the json file and the preview + stored on the external device. + + If the metadata is corrupted we do remove it and the preview as well. + + """ + metadata = None + metadata_path = os.path.join(dir_path, JOURNAL_METADATA_DIR, + filename + '.metadata') + preview_path = os.path.join(dir_path, JOURNAL_METADATA_DIR, + filename + '.preview') + + if not os.path.exists(metadata_path): + return None + + try: + metadata = simplejson.load(open(metadata_path)) + except (ValueError, EnvironmentError): + os.unlink(metadata_path) + if os.path.exists(preview_path): + os.unlink(preview_path) + logging.error('Could not read metadata for file %r on ' + 'external device.', filename) + return None + else: + metadata['uid'] = os.path.join(dir_path, filename) + + if not fetch_preview: + if 'preview' in metadata: + del(metadata['preview']) + else: + if os.path.exists(preview_path): + try: + metadata['preview'] = dbus.ByteArray(open(preview_path).read()) + except EnvironmentError: + logging.debug('Could not read preview for file %r on ' + 'external device.', filename) + + return metadata + + +def _get_datastore(): + global _datastore + if _datastore is None: + bus = dbus.SessionBus() + remote_object = bus.get_object(DS_DBUS_SERVICE, DS_DBUS_PATH) + _datastore = dbus.Interface(remote_object, DS_DBUS_INTERFACE) + + _datastore.connect_to_signal('Created', _datastore_created_cb) + _datastore.connect_to_signal('Updated', _datastore_updated_cb) + _datastore.connect_to_signal('Deleted', _datastore_deleted_cb) + + return _datastore + + +def _datastore_created_cb(object_id): + created.send(None, object_id=object_id) + + +def _datastore_updated_cb(object_id): + updated.send(None, object_id=object_id) + + +def _datastore_deleted_cb(object_id): + deleted.send(None, object_id=object_id) + + +def find(query_, page_size): + """Returns a ResultSet + """ + query = query_.copy() + + mount_points = query.pop('mountpoints', ['/']) + if mount_points is None or len(mount_points) != 1: + raise ValueError('Exactly one mount point must be specified') + + if mount_points[0] == '/': + return DatastoreResultSet(query, page_size) + else: + return InplaceResultSet(query, page_size, mount_points[0]) + + +def _get_mount_point(path): + dir_path = os.path.dirname(path) + while dir_path: + if os.path.ismount(dir_path): + return dir_path + else: + dir_path = dir_path.rsplit(os.sep, 1)[0] + return None + + +def get(object_id): + """Returns the metadata for an object + """ + if os.path.exists(object_id): + stat = os.stat(object_id) + metadata = _get_file_metadata(object_id, stat) + metadata['mountpoint'] = _get_mount_point(object_id) + else: + metadata = _get_datastore().get_properties(object_id, byte_arrays=True) + metadata['mountpoint'] = '/' + return metadata + + +def get_file(object_id): + """Returns the file for an object + """ + if os.path.exists(object_id): + logging.debug('get_file asked for file with path %r', object_id) + return object_id + else: + logging.debug('get_file asked for entry with id %r', object_id) + file_path = _get_datastore().get_filename(object_id) + if file_path: + return util.TempFilePath(file_path) + else: + return None + + +def get_file_size(object_id): + """Return the file size for an object + """ + logging.debug('get_file_size %r', object_id) + if os.path.exists(object_id): + return os.stat(object_id).st_size + + file_path = _get_datastore().get_filename(object_id) + if file_path: + size = os.stat(file_path).st_size + os.remove(file_path) + return size + + return 0 + + +def get_unique_values(key): + """Returns a list with the different values a property has taken + """ + empty_dict = dbus.Dictionary({}, signature='ss') + return _get_datastore().get_uniquevaluesfor(key, empty_dict) + + +def delete(object_id): + """Removes an object from persistent storage + """ + if not os.path.exists(object_id): + _get_datastore().delete(object_id) + else: + os.unlink(object_id) + dir_path = os.path.dirname(object_id) + filename = os.path.basename(object_id) + old_files = [os.path.join(dir_path, JOURNAL_METADATA_DIR, + filename + '.metadata'), + os.path.join(dir_path, JOURNAL_METADATA_DIR, + filename + '.preview')] + for old_file in old_files: + if os.path.exists(old_file): + try: + os.unlink(old_file) + except EnvironmentError: + logging.error('Could not remove metadata=%s ' + 'for file=%s', old_file, filename) + deleted.send(None, object_id=object_id) + + +def copy(metadata, mount_point): + """Copies an object to another mount point + """ + metadata = get(metadata['uid']) + if mount_point == '/' and metadata['icon-color'] == '#000000,#ffffff': + client = gconf.client_get_default() + metadata['icon-color'] = client.get_string('/desktop/sugar/user/color') + file_path = get_file(metadata['uid']) + if file_path is None: + file_path = '' + + metadata['mountpoint'] = mount_point + del metadata['uid'] + + return write(metadata, file_path, transfer_ownership=False) + + +def write(metadata, file_path='', update_mtime=True, transfer_ownership=True): + """Creates or updates an entry for that id + """ + logging.debug('model.write %r %r %r', metadata.get('uid', ''), file_path, + update_mtime) + if update_mtime: + metadata['mtime'] = datetime.now().isoformat() + metadata['timestamp'] = int(time.time()) + + if metadata.get('mountpoint', '/') == '/': + if metadata.get('uid', ''): + object_id = _get_datastore().update(metadata['uid'], + dbus.Dictionary(metadata), + file_path, + transfer_ownership) + else: + object_id = _get_datastore().create(dbus.Dictionary(metadata), + file_path, + transfer_ownership) + else: + object_id = _write_entry_on_external_device(metadata, file_path) + + return object_id + + +def _rename_entry_on_external_device(file_path, destination_path, + metadata_dir_path): + """Rename an entry with the associated metadata on an external device.""" + old_file_path = file_path + if old_file_path != destination_path: + os.rename(file_path, destination_path) + old_fname = os.path.basename(file_path) + old_files = [os.path.join(metadata_dir_path, + old_fname + '.metadata'), + os.path.join(metadata_dir_path, + old_fname + '.preview')] + for ofile in old_files: + if os.path.exists(ofile): + try: + os.unlink(ofile) + except EnvironmentError: + logging.error('Could not remove metadata=%s ' + 'for file=%s', ofile, old_fname) + + +def _write_entry_on_external_device(metadata, file_path): + """Create and update an entry copied from the + DS to an external storage device. + + Besides copying the associated file a file for the preview + and one for the metadata are stored in the hidden directory + .Sugar-Metadata. + + This function handles renames of an entry on the + external device and avoids name collisions. Renames are + handled failsafe. + + """ + if 'uid' in metadata and os.path.exists(metadata['uid']): + file_path = metadata['uid'] + + if not file_path or not os.path.exists(file_path): + raise ValueError('Entries without a file cannot be copied to ' + 'removable devices') + + if not metadata.get('title'): + metadata['title'] = _('Untitled') + file_name = get_file_name(metadata['title'], metadata['mime_type']) + + destination_path = os.path.join(metadata['mountpoint'], file_name) + if destination_path != file_path: + file_name = get_unique_file_name(metadata['mountpoint'], file_name) + destination_path = os.path.join(metadata['mountpoint'], file_name) + clean_name, extension_ = os.path.splitext(file_name) + metadata['title'] = clean_name + + metadata_copy = metadata.copy() + metadata_copy.pop('mountpoint', None) + metadata_copy.pop('uid', None) + metadata_copy.pop('filesize', None) + + metadata_dir_path = os.path.join(metadata['mountpoint'], + JOURNAL_METADATA_DIR) + if not os.path.exists(metadata_dir_path): + os.mkdir(metadata_dir_path) + + preview = None + if 'preview' in metadata_copy: + preview = metadata_copy['preview'] + preview_fname = file_name + '.preview' + metadata_copy.pop('preview', None) + + try: + metadata_json = simplejson.dumps(metadata_copy) + except (UnicodeDecodeError, EnvironmentError): + logging.error('Could not convert metadata to json.') + else: + (fh, fn) = tempfile.mkstemp(dir=metadata['mountpoint']) + os.write(fh, metadata_json) + os.close(fh) + os.rename(fn, os.path.join(metadata_dir_path, file_name + '.metadata')) + + if preview: + (fh, fn) = tempfile.mkstemp(dir=metadata['mountpoint']) + os.write(fh, preview) + os.close(fh) + os.rename(fn, os.path.join(metadata_dir_path, preview_fname)) + + if not os.path.dirname(destination_path) == os.path.dirname(file_path): + shutil.copy(file_path, destination_path) + else: + _rename_entry_on_external_device(file_path, destination_path, + metadata_dir_path) + + object_id = destination_path + created.send(None, object_id=object_id) + + return object_id + + +def get_file_name(title, mime_type): + file_name = title + + extension = mime.get_primary_extension(mime_type) + if extension is not None and extension: + extension = '.' + extension + if not file_name.endswith(extension): + file_name += extension + + # Invalid characters in VFAT filenames. From + # http://en.wikipedia.org/wiki/File_Allocation_Table + invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|', '\x7F'] + invalid_chars.extend([chr(x) for x in range(0, 32)]) + for char in invalid_chars: + file_name = file_name.replace(char, '_') + + # FAT limit is 255, leave some space for uniqueness + max_len = 250 + if len(file_name) > max_len: + name, extension = os.path.splitext(file_name) + file_name = name[0:max_len - len(extension)] + extension + + return file_name + + +def get_unique_file_name(mount_point, file_name): + if os.path.exists(os.path.join(mount_point, file_name)): + i = 1 + name, extension = os.path.splitext(file_name) + while len(file_name) <= 255: + file_name = name + '_' + str(i) + extension + if not os.path.exists(os.path.join(mount_point, file_name)): + break + i += 1 + + return file_name + + +def is_editable(metadata): + if metadata.get('mountpoint', '/') == '/': + return True + else: + return os.access(metadata['mountpoint'], os.W_OK) + + +def get_documents_path(): + """Gets the path of the DOCUMENTS folder + + If xdg-user-dir can not find the DOCUMENTS folder it returns + $HOME, which we omit. xdg-user-dir handles localization + (i.e. translation) of the filenames. + + Returns: Path to $HOME/DOCUMENTS or None if an error occurs + """ + try: + pipe = subprocess.Popen(['xdg-user-dir', 'DOCUMENTS'], + stdout=subprocess.PIPE) + documents_path = os.path.normpath(pipe.communicate()[0].strip()) + if os.path.exists(documents_path) and \ + os.environ.get('HOME') != documents_path: + return documents_path + except OSError, exception: + if exception.errno != errno.ENOENT: + logging.exception('Could not run xdg-user-dir') + return None diff --git a/src/jarabe/journal/objectchooser.py b/src/jarabe/journal/objectchooser.py new file mode 100644 index 0000000..ecb8ecf --- /dev/null +++ b/src/jarabe/journal/objectchooser.py @@ -0,0 +1,199 @@ +# Copyright (C) 2007, One Laptop Per Child +# +# 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 + +from gettext import gettext as _ +import logging + +import gobject +import gtk +import wnck + +from sugar.graphics import style +from sugar.graphics.toolbutton import ToolButton + +from jarabe.journal.listview import BaseListView +from jarabe.journal.listmodel import ListModel +from jarabe.journal.journaltoolbox import SearchToolbar +from jarabe.journal.volumestoolbar import VolumesToolbar + + +class ObjectChooser(gtk.Window): + + __gtype_name__ = 'ObjectChooser' + + __gsignals__ = { + 'response': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, ([int])), + } + + def __init__(self, parent=None, what_filter=''): + gtk.Window.__init__(self) + self.set_type_hint(gtk.gdk.WINDOW_TYPE_HINT_DIALOG) + self.set_decorated(False) + self.set_position(gtk.WIN_POS_CENTER_ALWAYS) + self.set_border_width(style.LINE_WIDTH) + + self._selected_object_id = None + + self.add_events(gtk.gdk.VISIBILITY_NOTIFY_MASK) + self.connect('visibility-notify-event', + self.__visibility_notify_event_cb) + self.connect('delete-event', self.__delete_event_cb) + self.connect('key-press-event', self.__key_press_event_cb) + + if parent is None: + logging.warning('ObjectChooser: No parent window specified') + else: + self.connect('realize', self.__realize_cb, parent) + + screen = wnck.screen_get_default() + screen.connect('window-closed', self.__window_closed_cb, parent) + + vbox = gtk.VBox() + self.add(vbox) + vbox.show() + + title_box = TitleBox() + title_box.connect('volume-changed', self.__volume_changed_cb) + title_box.close_button.connect('clicked', + self.__close_button_clicked_cb) + title_box.set_size_request(-1, style.GRID_CELL_SIZE) + vbox.pack_start(title_box, expand=False) + title_box.show() + + separator = gtk.HSeparator() + vbox.pack_start(separator, expand=False) + separator.show() + + self._toolbar = SearchToolbar() + self._toolbar.connect('query-changed', self.__query_changed_cb) + self._toolbar.set_size_request(-1, style.GRID_CELL_SIZE) + vbox.pack_start(self._toolbar, expand=False) + self._toolbar.show() + + self._list_view = ChooserListView() + self._list_view.connect('entry-activated', self.__entry_activated_cb) + vbox.pack_start(self._list_view) + self._list_view.show() + + self._toolbar.set_mount_point('/') + + width = gtk.gdk.screen_width() - style.GRID_CELL_SIZE * 2 + height = gtk.gdk.screen_height() - style.GRID_CELL_SIZE * 2 + self.set_size_request(width, height) + + if what_filter: + self._toolbar.set_what_filter(what_filter) + + def __realize_cb(self, chooser, parent): + self.window.set_transient_for(parent) + # TODO: Should we disconnect the signal here? + + def __window_closed_cb(self, screen, window, parent): + if window.get_xid() == parent.xid: + self.destroy() + + def __entry_activated_cb(self, list_view, uid): + self._selected_object_id = uid + self.emit('response', gtk.RESPONSE_ACCEPT) + + def __delete_event_cb(self, chooser, event): + self.emit('response', gtk.RESPONSE_DELETE_EVENT) + + def __key_press_event_cb(self, widget, event): + keyname = gtk.gdk.keyval_name(event.keyval) + if keyname == 'Escape': + self.emit('response', gtk.RESPONSE_DELETE_EVENT) + + def __close_button_clicked_cb(self, button): + self.emit('response', gtk.RESPONSE_DELETE_EVENT) + + def get_selected_object_id(self): + return self._selected_object_id + + def __query_changed_cb(self, toolbar, query): + self._list_view.update_with_query(query) + + def __volume_changed_cb(self, volume_toolbar, mount_point): + logging.debug('Selected volume: %r.', mount_point) + self._toolbar.set_mount_point(mount_point) + + def __visibility_notify_event_cb(self, window, event): + logging.debug('visibility_notify_event_cb %r', self) + visible = event.state == gtk.gdk.VISIBILITY_FULLY_OBSCURED + self._list_view.set_is_visible(visible) + + +class TitleBox(VolumesToolbar): + __gtype_name__ = 'TitleBox' + + def __init__(self): + VolumesToolbar.__init__(self) + + label = gtk.Label() + label.set_markup('<b>%s</b>' % _('Choose an object')) + label.set_alignment(0, 0.5) + self._add_widget(label, expand=True) + + self.close_button = ToolButton(icon_name='dialog-cancel') + self.close_button.set_tooltip(_('Close')) + self.insert(self.close_button, -1) + self.close_button.show() + + def _add_widget(self, widget, expand=False): + tool_item = gtk.ToolItem() + tool_item.set_expand(expand) + + tool_item.add(widget) + widget.show() + + self.insert(tool_item, -1) + tool_item.show() + + +class ChooserListView(BaseListView): + __gtype_name__ = 'ChooserListView' + + __gsignals__ = { + 'entry-activated': (gobject.SIGNAL_RUN_FIRST, + gobject.TYPE_NONE, + ([str])), + } + + def __init__(self): + BaseListView.__init__(self) + + self.cell_icon.props.show_palette = False + self.tree_view.props.hover_selection = True + + self.tree_view.connect('button-release-event', + self.__button_release_event_cb) + + def __entry_activated_cb(self, entry): + self.emit('entry-activated', entry) + + def __button_release_event_cb(self, tree_view, event): + if event.window != tree_view.get_bin_window(): + return False + + pos = tree_view.get_path_at_pos(int(event.x), int(event.y)) + if pos is None: + return False + + path, column_, x_, y_ = pos + uid = tree_view.get_model()[path][ListModel.COLUMN_UID] + self.emit('entry-activated', uid) + + return False diff --git a/src/jarabe/journal/palettes.py b/src/jarabe/journal/palettes.py new file mode 100644 index 0000000..8fc1e5d --- /dev/null +++ b/src/jarabe/journal/palettes.py @@ -0,0 +1,383 @@ +# Copyright (C) 2008 One Laptop Per Child +# +# 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 + +from gettext import gettext as _ +import logging +import os + +import gobject +import gtk +import gconf +import gio +import glib + +from sugar.graphics import style +from sugar.graphics.palette import Palette +from sugar.graphics.menuitem import MenuItem +from sugar.graphics.icon import Icon +from sugar.graphics.xocolor import XoColor +from sugar import mime + +from jarabe.model import friends +from jarabe.model import filetransfer +from jarabe.model import mimeregistry +from jarabe.journal import misc +from jarabe.journal import model + + +class ObjectPalette(Palette): + + __gtype_name__ = 'ObjectPalette' + + __gsignals__ = { + 'detail-clicked': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, + ([str])), + 'volume-error': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, + ([str, str])), + } + + def __init__(self, metadata, detail=False): + + self._metadata = metadata + + activity_icon = Icon(icon_size=gtk.ICON_SIZE_LARGE_TOOLBAR) + activity_icon.props.file = misc.get_icon_name(metadata) + color = misc.get_icon_color(metadata) + activity_icon.props.xo_color = color + + if 'title' in metadata: + title = gobject.markup_escape_text(metadata['title']) + else: + title = glib.markup_escape_text(_('Untitled')) + + Palette.__init__(self, primary_text=title, + icon=activity_icon) + + if misc.get_activities(metadata) or misc.is_bundle(metadata): + if metadata.get('activity_id', ''): + resume_label = _('Resume') + resume_with_label = _('Resume with') + else: + resume_label = _('Start') + resume_with_label = _('Start with') + menu_item = MenuItem(resume_label, 'activity-start') + menu_item.connect('activate', self.__start_activate_cb) + self.menu.append(menu_item) + menu_item.show() + + menu_item = MenuItem(resume_with_label, 'activity-start') + self.menu.append(menu_item) + menu_item.show() + start_with_menu = StartWithMenu(self._metadata) + menu_item.set_submenu(start_with_menu) + + else: + menu_item = MenuItem(_('No activity to start entry')) + menu_item.set_sensitive(False) + self.menu.append(menu_item) + menu_item.show() + + menu_item = MenuItem(_('Copy to')) + icon = Icon(icon_name='edit-copy', xo_color=color, + icon_size=gtk.ICON_SIZE_MENU) + menu_item.set_image(icon) + self.menu.append(menu_item) + menu_item.show() + copy_menu = CopyMenu(metadata) + copy_menu.connect('volume-error', self.__volume_error_cb) + menu_item.set_submenu(copy_menu) + + if self._metadata['mountpoint'] == '/': + menu_item = MenuItem(_('Duplicate')) + icon = Icon(icon_name='edit-duplicate', xo_color=color, + icon_size=gtk.ICON_SIZE_MENU) + menu_item.set_image(icon) + menu_item.connect('activate', self.__duplicate_activate_cb) + self.menu.append(menu_item) + menu_item.show() + + menu_item = MenuItem(_('Send to'), 'document-send') + self.menu.append(menu_item) + menu_item.show() + + friends_menu = FriendsMenu() + friends_menu.connect('friend-selected', self.__friend_selected_cb) + menu_item.set_submenu(friends_menu) + + if detail == True: + menu_item = MenuItem(_('View Details'), 'go-right') + menu_item.connect('activate', self.__detail_activate_cb) + self.menu.append(menu_item) + menu_item.show() + + menu_item = MenuItem(_('Erase'), 'list-remove') + menu_item.connect('activate', self.__erase_activate_cb) + self.menu.append(menu_item) + menu_item.show() + + def __start_activate_cb(self, menu_item): + misc.resume(self._metadata) + + def __duplicate_activate_cb(self, menu_item): + file_path = model.get_file(self._metadata['uid']) + try: + model.copy(self._metadata, '/') + except IOError, e: + logging.exception('Error while copying the entry. %s', e.strerror) + self.emit('volume-error', + _('Error while copying the entry. %s') % e.strerror, + _('Error')) + + def __erase_activate_cb(self, menu_item): + model.delete(self._metadata['uid']) + + def __detail_activate_cb(self, menu_item): + self.emit('detail-clicked', self._metadata['uid']) + + def __volume_error_cb(self, menu_item, message, severity): + self.emit('volume-error', message, severity) + + def __friend_selected_cb(self, menu_item, buddy): + logging.debug('__friend_selected_cb') + file_name = model.get_file(self._metadata['uid']) + + if not file_name or not os.path.exists(file_name): + logging.warn('Entries without a file cannot be sent.') + self.emit('volume-error', + _('Entries without a file cannot be sent.'), + _('Warning')) + return + + title = str(self._metadata['title']) + description = str(self._metadata.get('description', '')) + mime_type = str(self._metadata['mime_type']) + + if not mime_type: + mime_type = mime.get_for_file(file_name) + + filetransfer.start_transfer(buddy, file_name, title, description, + mime_type) + + +class CopyMenu(gtk.Menu): + __gtype_name__ = 'JournalCopyMenu' + + __gsignals__ = { + 'volume-error': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, + ([str, str])), + } + + def __init__(self, metadata): + gobject.GObject.__init__(self) + + self._metadata = metadata + + clipboard_menu = ClipboardMenu(self._metadata) + clipboard_menu.set_image(Icon(icon_name='toolbar-edit', + icon_size=gtk.ICON_SIZE_MENU)) + clipboard_menu.connect('volume-error', self.__volume_error_cb) + self.append(clipboard_menu) + clipboard_menu.show() + + if self._metadata['mountpoint'] != '/': + client = gconf.client_get_default() + color = XoColor(client.get_string('/desktop/sugar/user/color')) + journal_menu = VolumeMenu(self._metadata, _('Journal'), '/') + journal_menu.set_image(Icon(icon_name='activity-journal', + xo_color=color, + icon_size=gtk.ICON_SIZE_MENU)) + journal_menu.connect('volume-error', self.__volume_error_cb) + self.append(journal_menu) + journal_menu.show() + + volume_monitor = gio.volume_monitor_get() + icon_theme = gtk.icon_theme_get_default() + for mount in volume_monitor.get_mounts(): + if self._metadata['mountpoint'] == mount.get_root().get_path(): + continue + volume_menu = VolumeMenu(self._metadata, mount.get_name(), + mount.get_root().get_path()) + for name in mount.get_icon().props.names: + if icon_theme.has_icon(name): + volume_menu.set_image(Icon(icon_name=name, + icon_size=gtk.ICON_SIZE_MENU)) + break + volume_menu.connect('volume-error', self.__volume_error_cb) + self.append(volume_menu) + volume_menu.show() + + def __volume_error_cb(self, menu_item, message, severity): + self.emit('volume-error', message, severity) + + +class VolumeMenu(MenuItem): + __gtype_name__ = 'JournalVolumeMenu' + + __gsignals__ = { + 'volume-error': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, + ([str, str])), + } + + def __init__(self, metadata, label, mount_point): + MenuItem.__init__(self, label) + self._metadata = metadata + self.connect('activate', self.__copy_to_volume_cb, mount_point) + + def __copy_to_volume_cb(self, menu_item, mount_point): + file_path = model.get_file(self._metadata['uid']) + + if not file_path or not os.path.exists(file_path): + logging.warn('Entries without a file cannot be copied.') + self.emit('volume-error', + _('Entries without a file cannot be copied.'), + _('Warning')) + return + + try: + model.copy(self._metadata, mount_point) + except IOError, e: + logging.exception('Error while copying the entry. %s', e.strerror) + self.emit('volume-error', + _('Error while copying the entry. %s') % e.strerror, + _('Error')) + + +class ClipboardMenu(MenuItem): + __gtype_name__ = 'JournalClipboardMenu' + + __gsignals__ = { + 'volume-error': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, + ([str, str])), + } + + def __init__(self, metadata): + MenuItem.__init__(self, _('Clipboard')) + + self._temp_file_path = None + self._metadata = metadata + self.connect('activate', self.__copy_to_clipboard_cb) + + def __copy_to_clipboard_cb(self, menu_item): + file_path = model.get_file(self._metadata['uid']) + if not file_path or not os.path.exists(file_path): + logging.warn('Entries without a file cannot be copied.') + self.emit('volume-error', + _('Entries without a file cannot be copied.'), + _('Warning')) + return + + clipboard = gtk.Clipboard() + clipboard.set_with_data([('text/uri-list', 0, 0)], + self.__clipboard_get_func_cb, + self.__clipboard_clear_func_cb) + + def __clipboard_get_func_cb(self, clipboard, selection_data, info, data): + # Get hold of a reference so the temp file doesn't get deleted + self._temp_file_path = model.get_file(self._metadata['uid']) + logging.debug('__clipboard_get_func_cb %r', self._temp_file_path) + selection_data.set_uris(['file://' + self._temp_file_path]) + + def __clipboard_clear_func_cb(self, clipboard, data): + # Release and delete the temp file + self._temp_file_path = None + + +class FriendsMenu(gtk.Menu): + __gtype_name__ = 'JournalFriendsMenu' + + __gsignals__ = { + 'friend-selected': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, + ([object])), + } + + def __init__(self): + gobject.GObject.__init__(self) + + if filetransfer.file_transfer_available(): + friends_model = friends.get_model() + for friend in friends_model: + if friend.is_present(): + menu_item = MenuItem(text_label=friend.get_nick(), + icon_name='computer-xo', + xo_color=friend.get_color()) + menu_item.connect('activate', self.__item_activate_cb, + friend) + self.append(menu_item) + menu_item.show() + + if not self.get_children(): + menu_item = MenuItem(_('No friends present')) + menu_item.set_sensitive(False) + self.append(menu_item) + menu_item.show() + else: + menu_item = MenuItem(_('No valid connection found')) + menu_item.set_sensitive(False) + self.append(menu_item) + menu_item.show() + + def __item_activate_cb(self, menu_item, friend): + self.emit('friend-selected', friend) + + +class StartWithMenu(gtk.Menu): + __gtype_name__ = 'JournalStartWithMenu' + + def __init__(self, metadata): + gobject.GObject.__init__(self) + + self._metadata = metadata + + for activity_info in misc.get_activities(metadata): + menu_item = MenuItem(activity_info.get_name()) + menu_item.set_image(Icon(file=activity_info.get_icon(), + icon_size=gtk.ICON_SIZE_MENU)) + menu_item.connect('activate', self.__item_activate_cb, + activity_info.get_bundle_id()) + self.append(menu_item) + menu_item.show() + + if not self.get_children(): + if metadata.get('activity_id', ''): + resume_label = _('No activity to resume entry') + else: + resume_label = _('No activity to start entry') + menu_item = MenuItem(resume_label) + menu_item.set_sensitive(False) + self.append(menu_item) + menu_item.show() + + def __item_activate_cb(self, menu_item, service_name): + mime_type = self._metadata.get('mime_type', '') + if mime_type: + mime_registry = mimeregistry.get_registry() + mime_registry.set_default_activity(mime_type, service_name) + misc.resume(self._metadata, service_name) + + +class BuddyPalette(Palette): + def __init__(self, buddy): + self._buddy = buddy + + nick, colors = buddy + buddy_icon = Icon(icon_name='computer-xo', + icon_size=style.STANDARD_ICON_SIZE, + xo_color=XoColor(colors)) + + Palette.__init__(self, primary_text=glib.markup_escape_text(nick), + icon=buddy_icon) + + # TODO: Support actions on buddies, like make friend, invite, etc. diff --git a/src/jarabe/journal/volumestoolbar.py b/src/jarabe/journal/volumestoolbar.py new file mode 100644 index 0000000..71b6ea8 --- /dev/null +++ b/src/jarabe/journal/volumestoolbar.py @@ -0,0 +1,404 @@ +# Copyright (C) 2007, 2011, One Laptop Per Child +# +# 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 logging +import os +import statvfs +from gettext import gettext as _ + +import gobject +import gio +import glib +import gtk +import gconf +import cPickle +import xapian +import simplejson +import tempfile +import shutil + +from sugar.graphics.radiotoolbutton import RadioToolButton +from sugar.graphics.palette import Palette +from sugar.graphics.xocolor import XoColor +from sugar import env + +from jarabe.journal import model +from jarabe.view.palettes import VolumePalette + + +_JOURNAL_0_METADATA_DIR = '.olpc.store' + + +def _get_id(document): + """Get the ID for the document in the xapian database.""" + tl = document.termlist() + try: + term = tl.skip_to('Q').term + if len(term) == 0 or term[0] != 'Q': + return None + return term[1:] + except StopIteration: + return None + + +def _convert_entries(root): + """Convert entries written by the datastore version 0. + + The metadata and the preview will be written using the new + scheme for writing Journal entries to removable storage + devices. + + - entries that do not have an associated file are not + converted. + - if an entry has no title we set it to Untitled and rename + the file accordingly, taking care of creating a unique + filename + + """ + try: + database = xapian.Database(os.path.join(root, _JOURNAL_0_METADATA_DIR, + 'index')) + except xapian.DatabaseError: + logging.exception('Convert DS-0 Journal entries: error reading db: %s', + os.path.join(root, _JOURNAL_0_METADATA_DIR, 'index')) + return + + metadata_dir_path = os.path.join(root, model.JOURNAL_METADATA_DIR) + if not os.path.exists(metadata_dir_path): + try: + os.mkdir(metadata_dir_path) + except EnvironmentError: + logging.error('Convert DS-0 Journal entries: ' + 'error creating the Journal metadata directory.') + return + + for posting_item in database.postlist(''): + try: + document = database.get_document(posting_item.docid) + except xapian.DocNotFoundError, e: + logging.debug('Convert DS-0 Journal entries: error getting ' + 'document %s: %s', posting_item.docid, e) + continue + _convert_entry(root, document) + + +def _convert_entry(root, document): + try: + metadata_loaded = cPickle.loads(document.get_data()) + except cPickle.PickleError, e: + logging.debug('Convert DS-0 Journal entries: ' + 'error converting metadata: %s', e) + return + + if not ('activity_id' in metadata_loaded and + 'mime_type' in metadata_loaded and + 'title' in metadata_loaded): + return + + metadata = {} + + uid = _get_id(document) + if uid is None: + return + + for key, value in metadata_loaded.items(): + metadata[str(key)] = str(value[0]) + + if 'uid' not in metadata: + metadata['uid'] = uid + + filename = metadata.pop('filename', None) + if not filename: + return + if not os.path.exists(os.path.join(root, filename)): + return + + if not metadata.get('title'): + metadata['title'] = _('Untitled') + fn = model.get_file_name(metadata['title'], + metadata['mime_type']) + new_filename = model.get_unique_file_name(root, fn) + os.rename(os.path.join(root, filename), + os.path.join(root, new_filename)) + filename = new_filename + + preview_path = os.path.join(root, _JOURNAL_0_METADATA_DIR, + 'preview', uid) + if os.path.exists(preview_path): + preview_fname = filename + '.preview' + new_preview_path = os.path.join(root, + model.JOURNAL_METADATA_DIR, + preview_fname) + if not os.path.exists(new_preview_path): + shutil.copy(preview_path, new_preview_path) + + metadata_fname = filename + '.metadata' + metadata_path = os.path.join(root, model.JOURNAL_METADATA_DIR, + metadata_fname) + if not os.path.exists(metadata_path): + (fh, fn) = tempfile.mkstemp(dir=root) + os.write(fh, simplejson.dumps(metadata)) + os.close(fh) + os.rename(fn, metadata_path) + + logging.debug('Convert DS-0 Journal entries: entry converted: ' + 'file=%s metadata=%s', + os.path.join(root, filename), metadata) + + +class VolumesToolbar(gtk.Toolbar): + __gtype_name__ = 'VolumesToolbar' + + __gsignals__ = { + 'volume-changed': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, + ([str])), + 'volume-error': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, + ([str, str])), + } + + def __init__(self): + gtk.Toolbar.__init__(self) + self._mount_added_hid = None + self._mount_removed_hid = None + + button = JournalButton() + button.connect('toggled', self._button_toggled_cb) + self.insert(button, 0) + button.show() + self._volume_buttons = [button] + + self.connect('destroy', self.__destroy_cb) + + gobject.idle_add(self._set_up_volumes) + + def __destroy_cb(self, widget): + volume_monitor = gio.volume_monitor_get() + volume_monitor.disconnect(self._mount_added_hid) + volume_monitor.disconnect(self._mount_removed_hid) + + def _set_up_volumes(self): + self._set_up_documents_button() + + volume_monitor = gio.volume_monitor_get() + self._mount_added_hid = volume_monitor.connect('mount-added', + self.__mount_added_cb) + self._mount_removed_hid = volume_monitor.connect('mount-removed', + self.__mount_removed_cb) + + for mount in volume_monitor.get_mounts(): + self._add_button(mount) + + def _set_up_documents_button(self): + documents_path = model.get_documents_path() + if documents_path is not None: + button = DocumentsButton(documents_path) + button.props.group = self._volume_buttons[0] + label = glib.markup_escape_text(_('Documents')) + button.set_palette(Palette(label)) + button.connect('toggled', self._button_toggled_cb) + button.show() + + position = self.get_item_index(self._volume_buttons[-1]) + 1 + self.insert(button, position) + self._volume_buttons.append(button) + self.show() + + def __mount_added_cb(self, volume_monitor, mount): + self._add_button(mount) + + def __mount_removed_cb(self, volume_monitor, mount): + self._remove_button(mount) + + def _add_button(self, mount): + logging.debug('VolumeToolbar._add_button: %r', mount.get_name()) + + if os.path.exists(os.path.join(mount.get_root().get_path(), + _JOURNAL_0_METADATA_DIR)): + logging.debug('Convert DS-0 Journal entries: starting conversion') + gobject.idle_add(_convert_entries, mount.get_root().get_path()) + + button = VolumeButton(mount) + button.props.group = self._volume_buttons[0] + button.connect('toggled', self._button_toggled_cb) + button.connect('volume-error', self.__volume_error_cb) + position = self.get_item_index(self._volume_buttons[-1]) + 1 + self.insert(button, position) + button.show() + + self._volume_buttons.append(button) + + if len(self.get_children()) > 1: + self.show() + + def __volume_error_cb(self, button, strerror, severity): + self.emit('volume-error', strerror, severity) + + def _button_toggled_cb(self, button): + if button.props.active: + self.emit('volume-changed', button.mount_point) + + def _unmount_activated_cb(self, menu_item, mount): + logging.debug('VolumesToolbar._unmount_activated_cb: %r', mount) + mount.unmount(self.__unmount_cb) + + def __unmount_cb(self, source, result): + logging.debug('__unmount_cb %r %r', source, result) + + def _get_button_for_mount(self, mount): + mount_point = mount.get_root().get_path() + for button in self.get_children(): + if button.mount_point == mount_point: + return button + logging.error('Couldnt find button with mount_point %r', mount_point) + return None + + def _remove_button(self, mount): + button = self._get_button_for_mount(mount) + self._volume_buttons.remove(button) + self.remove(button) + self.get_children()[0].props.active = True + + if len(self.get_children()) < 2: + self.hide() + + def set_active_volume(self, mount): + button = self._get_button_for_mount(mount) + button.props.active = True + + +class BaseButton(RadioToolButton): + __gsignals__ = { + 'volume-error': (gobject.SIGNAL_RUN_FIRST, gobject.TYPE_NONE, + ([str, str])), + } + + def __init__(self, mount_point): + RadioToolButton.__init__(self) + + self.mount_point = mount_point + + self.drag_dest_set(gtk.DEST_DEFAULT_ALL, + [('journal-object-id', 0, 0)], + gtk.gdk.ACTION_COPY) + self.connect('drag-data-received', self._drag_data_received_cb) + + def _drag_data_received_cb(self, widget, drag_context, x, y, + selection_data, info, timestamp): + object_id = selection_data.data + metadata = model.get(object_id) + file_path = model.get_file(metadata['uid']) + if not file_path or not os.path.exists(file_path): + logging.warn('Entries without a file cannot be copied.') + self.emit('volume-error', + _('Entries without a file cannot be copied.'), + _('Warning')) + return + + try: + model.copy(metadata, self.mount_point) + except IOError, e: + logging.exception('Error while copying the entry. %s', e.strerror) + self.emit('volume-error', + _('Error while copying the entry. %s') % e.strerror, + _('Error')) + + +class VolumeButton(BaseButton): + def __init__(self, mount): + self._mount = mount + mount_point = mount.get_root().get_path() + BaseButton.__init__(self, mount_point) + + icon_name = None + icon_theme = gtk.icon_theme_get_default() + for icon_name in mount.get_icon().props.names: + icon_info = icon_theme.lookup_icon(icon_name, + gtk.ICON_SIZE_LARGE_TOOLBAR, 0) + if icon_info is not None: + break + + if icon_name is None: + icon_name = 'drive' + + self.props.named_icon = icon_name + + # TODO: retrieve the colors from the owner of the device + client = gconf.client_get_default() + color = XoColor(client.get_string('/desktop/sugar/user/color')) + self.props.xo_color = color + + def create_palette(self): + palette = VolumePalette(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='/') + + self.props.named_icon = 'activity-journal' + + client = gconf.client_get_default() + color = XoColor(client.get_string('/desktop/sugar/user/color')) + self.props.xo_color = color + + def create_palette(self): + palette = JournalButtonPalette(self) + return palette + + +class JournalButtonPalette(Palette): + + def __init__(self, mount): + Palette.__init__(self, glib.markup_escape_text(_('Journal'))) + vbox = gtk.VBox() + self.set_content(vbox) + vbox.show() + + self._progress_bar = gtk.ProgressBar() + vbox.add(self._progress_bar) + self._progress_bar.show() + + self._free_space_label = gtk.Label() + self._free_space_label.set_alignment(0.5, 0.5) + vbox.add(self._free_space_label) + self._free_space_label.show() + + self.connect('popup', self.__popup_cb) + + def __popup_cb(self, palette): + stat = os.statvfs(env.get_profile_path()) + free_space = stat[statvfs.F_BSIZE] * stat[statvfs.F_BAVAIL] + total_space = stat[statvfs.F_BSIZE] * stat[statvfs.F_BLOCKS] + + fraction = (total_space - free_space) / float(total_space) + self._progress_bar.props.fraction = fraction + self._free_space_label.props.label = _('%(free_space)d MB Free') % \ + {'free_space': free_space / (1024 * 1024)} + + +class DocumentsButton(BaseButton): + + def __init__(self, documents_path): + BaseButton.__init__(self, mount_point=documents_path) + + self.props.named_icon = 'user-documents' + + client = gconf.client_get_default() + color = XoColor(client.get_string('/desktop/sugar/user/color')) + self.props.xo_color = color |