Jugar con el Journal
Introducción
Por defecto, cada Actividad genera y lee una entrada en el Journal (Diario). La mayoría de las Actividades no hace nada más que esto en relación al Diario, si tu Actividad es de este tipo no necesitarás leer el contenido de este capítulo. Sin embargo para el día en que hagas Actividades más elaboradas, te conviene seguir leyendo.
Primero repasemos que es el Journal. El Journal, es una colección de archivos con cierta metadata (data por encima de la data) asociada a ellos. La metadata, está guardada como cadenas de texto y incluye cosas como Title, Description, Tags, MIME Type (Título, descripción, etiquetas, tipos MIME) y una captura de pantalla del último acceso a la Actividad.
Estos archivos de metadata no son leídos directamente por tu Actividad. Sugar provee una Interfaz de Programación de Aplicaciones ( API Application Programming Interface). Esta API proporciona métodos para agregar, borrar y modificar entradas del Journal, así como métodos de búsqueda y listado de entradas que coincidan con algún criterio.
En el paquete datastore se encuentra la API que usaremos. Después de la versión .82 esta API fue reescrita así que deberemos aprender como hacer para que nuestra Actividad tenga soporte para ambas versiones.
Si has venido leyendo este libro hasta ahora, ya habrás notado más de un caso donde Sugar comienza con un proceder inicial que luego cambia para incluir mejoras, pero siempre brinda la opción de que las Actividades elijan trabajar con los métodos viejos. Si te preguntas si es normal para un proyecto proceder de esta forma; te digo, como programador profesional que los trucos para conservar la retro-compatibilidad son archi-comunes, y que Sugar no hace más trucos que los habituales. Cuando Herman Hollerith tabuló el censo de 18901 con tarjetas perforadas, tomó decisiones con las que los programadores de hoy día deben lidiar aun.
Presentación del Sugar Commander
Aunque soy un gran fan del concepto del Journal, no soy muy amigo de la Actividad Journal que Sugar usa para navegar el Journal y para mantenerlo. Mi mayor queja es que representa el contenido de los dispositivos de memoria como pendrives y tarjetas SD, como si estos ficheros estuvieran en el Journal. Mi postura es que los archivos y ficheros son una cosa, y que el Journal es otra cosa, por lo que la interfaz de usuario debería distinguir bien esto.
La Actividad Journal no es una Actividad en el sentido estricto. Hereda código de la clase Activity como cualquier otra Actividad, está escrita en Python y usa la misma datastore API que todas las Actividades. Sin embargo, se ejecuta de una forma particular que deriva en permisos y habilidades que están más allá que los de una Actividad común. En particular hace dos cosas:
-
Puede escribir sobre archivos en dispositivos externos como pendrives y tarjetas SD.
-
Puede utilizarse por si sola para retomar entradas del Diario que son de uso de otra Actividad.
Si quisiera escribir una Actividad Journal que hiciera lo mismo que la original, pero con una interfaz de usuario más a mi gusto, el modelo de seguridad de Sugar no me lo permitiría. Una versión más moderada podría ser útil igual. Así como cuando Kal-El, de vez en cuando elige ser Clark Kent en vez de Superman, mi Actividad Journal puede ser una alternativa valiosa a la Actividad Journal incorporada cuando no se necesiten super poderes.
Mi Actividad, a la que llamo Sugar Commander, tiene dos pestañas. Una representa al Journal y se ve así:

En esta pestaña se puede navegar sobre el contenido del Diario, ordenarlo por Título o por Tipo MIME, seleccionar entradas, ver detalles, editar Título, Descripción o Etiquetas, y borrar entradas no deseadas. La otra pestaña muestra archivos y carpetas y se ve así:

Esta pestaña permite navegar por archivos y directorios del sistema de archivos regular, incluyendo pendrives y tarjetas SD. Permite también, seleccionar un archivo y convertirlo en una entrada del Diario apretando el botón al pie de la pantalla.
Esta Actividad tiene muy poco código y sin embargo logra hacer todo lo que otra Actividad en relación al Diario. Con este comando puedes descargarla desde el repositorio Git.
git clone git://git.sugarlabs.org/sugar-commander/\mainline.git
Hay un solo archivo fuente, sugarcommander.py:
import logging
import os
import gtk
import pango
import zipfile
from sugar import mime
from sugar.activity import activity
from sugar.datastore import datastore
from sugar.graphics.alert import NotifyAlert
from sugar.graphics import style
from gettext import gettext as _
import gobject
import dbus
COLUMN_TITLE = 0
COLUMN_MIME = 1
COLUMN_JOBJECT = 2
DS_DBUS_SERVICE = 'org.laptop.sugar.DataStore'
DS_DBUS_INTERFACE = 'org.laptop.sugar.DataStore'
DS_DBUS_PATH = '/org/laptop/sugar/DataStore'
_logger = logging.getLogger('sugar-commander')
class SugarCommander(activity.Activity):
def __init__(self, handle, create_jobject=True):
"The entry point to the Activity"
activity.Activity.__init__(self, handle, False)
self.selected_journal_entry = None
self.selected_path = None
canvas = gtk.Notebook()
canvas.props.show_border = True
canvas.props.show_tabs = True
canvas.show()
self.ls_journal = gtk.ListStore(
gobject.TYPE_STRING,
gobject.TYPE_STRING,
gobject.TYPE_PYOBJECT)
self.tv_journal = gtk.TreeView(self.ls_journal)
self.tv_journal.set_rules_hint(True)
self.tv_journal.set_searcolumn(COLUMN_TITLE)
self.selection_journal = \
self.tv_journal.get_selection()
self.selection_journal.set_mode(
gtk.SELECTION_SINGLE)
self.selection_journal.connect("changed",
self.selection_journal_cb)
renderer = gtk.CellRendererText()
renderer.set_property('wrap-mode', gtk.WRAP_WORD)
renderer.set_property('wrap-width', 500)
renderer.set_property('width', 500)
self.col_journal = gtk.TreeViewColumn(_('Title'),
renderer, text=COLUMN_TITLE)
self.col_journal.set_sort_column_id(COLUMN_TITLE)
self.tv_journal.append_column(self.col_journal)
mime_renderer = gtk.CellRendererText()
mime_renderer.set_property('width', 500)
self.col_mime = gtk.TreeViewColumn(_('MIME'),
mime_renderer, text=COLUMN_MIME)
self.col_mime.set_sort_column_id(COLUMN_MIME)
self.tv_journal.append_column(self.col_mime)
self.list_scroller_journal = gtk.ScrolledWindow(
hadjustment=None, vadjustment=None)
self.list_scroller_journal.set_policy(
gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
self.list_scroller_journal.add(self.tv_journal)
label_attributes = pango.AttrList()
label_attributes.insert(pango.AttrSize(
14000, 0, -1))
label_attributes.insert(pango.AttrForeground(
65535, 65535, 65535, 0, -1))
tab1_label = gtk.Label(_("Journal"))
tab1_label.set_attributes(label_attributes)
tab1_label.show()
self.tv_journal.show()
self.list_scroller_journal.show()
column_table = gtk.Table(rows=1, columns=2,
homogeneous = False)
image_table = gtk.Table(rows=2, columns=2,
homogeneous=False)
self.image = gtk.Image()
image_table.attach(self.image, 0, 2, 0, 1,
xoptions=gtk.FILL|gtk.SHRINK,
yoptions=gtk.FILL|gtk.SHRINK,
xpadding=10,
ypadding=10)
self.btn_save = gtk.Button(_("Save"))
self.btn_save.connect('button_press_event',
self.save_button_press_event_cb)
image_table.attach(self.btn_save, 0, 1, 1, 2,
xoptions=gtk.SHRINK,
yoptions=gtk.SHRINK, xpadding=10,
ypadding=10)
self.btn_save.props.sensitive = False
self.btn_save.show()
self.btn_delete = gtk.Button(_("Delete"))
self.btn_delete.connect('button_press_event',
self.delete_button_press_event_cb)
image_table.attach(self.btn_delete, 1, 2, 1, 2,
xoptions=gtk.SHRINK,
yoptions=gtk.SHRINK, xpadding=10,
ypadding=10)
self.btn_delete.props.sensitive = False
self.btn_delete.show()
column_table.attach(image_table, 0, 1, 0, 1,
xoptions=gtk.FILL|gtk.SHRINK,
yoptions=gtk.SHRINK, xpadding=10,
ypadding=10)
entry_table = gtk.Table(rows=3, columns=2,
homogeneous=False)
title_label = gtk.Label(_("Title"))
entry_table.attach(title_label, 0, 1, 0, 1,
xoptions=gtk.SHRINK,
yoptions=gtk.SHRINK,
xpadding=10, ypadding=10)
title_label.show()
self.title_entry = gtk.Entry(max=0)
entry_table.attach(self.title_entry, 1, 2, 0, 1,
xoptions=gtk.FILL|gtk.SHRINK,
yoptions=gtk.SHRINK, xpadding=10, ypadding=10)
self.title_entry.connect('key_press_event',
self.key_press_event_cb)
self.title_entry.show()
description_label = gtk.Label(_("Description"))
entry_table.attach(description_label, 0, 1, 1, 2,
xoptions=gtk.SHRINK,
yoptions=gtk.SHRINK,
xpadding=10, ypadding=10)
description_label.show()
self.description_textview = gtk.TextView()
self.description_textview.set_wrap_mode(
gtk.WRAP_WORD)
entry_table.attach(self.description_textview,
1, 2, 1, 2,
xoptions=gtk.EXPAND|gtk.FILL|gtk.SHRINK,
yoptions=gtk.EXPAND|gtk.FILL|gtk.SHRINK,
xpadding=10, ypadding=10)
self.description_textview.props.accepts_tab = False
self.description_textview.connect('key_press_event',
self.key_press_event_cb)
self.description_textview.show()
tags_label = gtk.Label(_("Tags"))
entry_table.attach(tags_label, 0, 1, 2, 3,
xoptions=gtk.SHRINK,
yoptions=gtk.SHRINK,
xpadding=10, ypadding=10)
tags_label.show()
self.tags_textview = gtk.TextView()
self.tags_textview.set_wrap_mode(gtk.WRAP_WORD)
entry_table.attach(self.tags_textview, 1, 2, 2, 3,
xoptions=gtk.FILL,
yoptions=gtk.EXPAND|gtk.FILL,
xpadding=10, ypadding=10)
self.tags_textview.props.accepts_tab = False
self.tags_textview.connect('key_press_event',
self.key_press_event_cb)
self.tags_textview.show()
entry_table.show()
self.scroller_entry = gtk.ScrolledWindow(
hadjustment=None, vadjustment=None)
self.scroller_entry.set_policy(gtk.POLICY_NEVER,
gtk.POLICY_AUTOMATIC)
self.scroller_entry.add_with_viewport(entry_table)
self.scroller_entry.show()
column_table.attach(self.scroller_entry,
1, 2, 0, 1,
xoptions=gtk.FILL|gtk.EXPAND|gtk.SHRINK,
yoptions=gtk.FILL|gtk.EXPAND|gtk.SHRINK,
xpadding=10, ypadding=10)
image_table.show()
column_table.show()
vbox = gtk.VBox(homogeneous=True, spacing=5)
vbox.pack_start(column_table)
vbox.pack_end(self.list_scroller_journal)
canvas.append_page(vbox, tab1_label)
self._filechooser = gtk.FileChooserWidget(
action=gtk.FILE_CHOOSER_ACTION_OPEN,
backend=None)
self._filechooser.set_current_folder("/media")
self.copy_button = gtk.Button(
_("Copy File To The Journal"))
self.copy_button.connect('clicked',
self.create_journal_entry)
self.copy_button.show()
self._filechooser.set_extra_widget(self.copy_button)
preview = gtk.Image()
self._filechooser.set_preview_widget(preview)
self._filechooser.connect("update-preview",
self.update_preview_cb, preview)
tab2_label = gtk.Label(_("Files"))
tab2_label.set_attributes(label_attributes)
tab2_label.show()
canvas.append_page(self._filechooser, tab2_label)
self.set_canvas(canvas)
self.show_all()
toolbox = activity.ActivityToolbox(self)
activity_toolbar = toolbox.get_activity_toolbar()
activity_toolbar.keep.props.visible = False
activity_toolbar.share.props.visible = False
self.set_toolbox(toolbox)
toolbox.show()
self.load_journal_table()
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',
self.datastore_created_cb)
_datastore.connect_to_signal('Updated',
self.datastore_updated_cb)
_datastore.connect_to_signal('Deleted',
self.datastore_deleted_cb)
self.selected_journal_entry = None
def update_preview_cb(self, file_chooser, preview):
filename = file_chooser.get_preview_filename()
try:
file_mimetype = mime.get_for_file(filename)
if file_mimetype.startswith('image/'):
pixbuf = \
gtk.gdk.pixbuf_new_from_file_at_size(
filename,
style.zoom(320), style.zoom(240))
preview.set_from_pixbuf(pixbuf)
have_preview = True
elif file_mimetype == 'application/x-cbz':
fname = self.extract_image(filename)
pixbuf = \
gtk.gdk.pixbuf_new_from_file_at_size(
fname,
style.zoom(320), style.zoom(240))
preview.set_from_pixbuf(pixbuf)
have_preview = True
os.remove(fname)
else:
have_preview = False
except:
have_preview = False
file_chooser.set_preview_widget_active(
have_preview)
return
def key_press_event_cb(self, entry, event):
self.btn_save.props.sensitive = True
def save_button_press_event_cb(self, entry, event):
self.update_entry()
def delete_button_press_event_cb(self, entry, event):
datastore.delete(
self.selected_journal_entry.object_id)
def datastore_created_cb(self, uid):
new_jobject = datastore.get(uid)
iter = self.ls_journal.append()
title = new_jobject.metadata['title']
self.ls_journal.set(iter, COLUMN_TITLE, title)
mime = new_jobject.metadata['mime_type']
self.ls_journal.set(iter, COLUMN_MIME, mime)
self.ls_journal.set(iter, COLUMN_JOBJECT,
new_jobject)
def datastore_updated_cb(self, uid):
new_jobject = datastore.get(uid)
iter = self.ls_journal.get_iter_first()
for row in self.ls_journal:
jobject = row[COLUMN_JOBJECT]
if jobject.object_id == uid:
title = new_jobject.metadata['title']
self.ls_journal.set_value(iter,
COLUMN_TITLE, title)
break
iter = self.ls_journal.iter_next(iter)
object_id = self.selected_journal_entry.object_id
if object_id == uid:
self.set_form_fields(new_jobject)
def datastore_deleted_cb(self, uid):
save_path = self.selected_path
iter = self.ls_journal.get_iter_first()
for row in self.ls_journal:
jobject = row[COLUMN_JOBJECT]
if jobject.object_id == uid:
self.ls_journal.remove(iter)
break
iter = self.ls_journal.iter_next(iter)
try:
self.selection_journal.select_path(save_path)
self.tv_journal.grab_focus()
except:
self.title_entry.set_text('')
description_textbuffer = \
self.description_textview.get_buffer()
description_textbuffer.set_text('')
tags_textbuffer = \
self.tags_textview.get_buffer()
tags_textbuffer.set_text('')
self.btn_save.props.sensitive = False
self.btn_delete.props.sensitive = False
self.image.clear()
self.image.show()
def update_entry(self):
needs_update = False
if self.selected_journal_entry is None:
return
object_id = self.selected_journal_entry.object_id
jobject = datastore.get(object_id)
old_title = jobject.metadata.get('title', None)
if old_title != self.title_entry.props.text:
jobject.metadata['title'] = \
self.title_entry.props.text
jobject.metadata['title_set_by_user'] = '1'
needs_update = True
old_tags = jobject.metadata.get('tags', None)
new_tags = \
self.tags_textview.props.buffer.props.text
if old_tags != new_tags:
jobject.metadata['tags'] = new_tags
needs_update = True
old_description = jobject.metadata.get(
'description', None)
new_description = \
self.description_textview.props.buffer.props.text
if old_description != new_description:
jobject.metadata['description'] = new_description
needs_update = True
if needs_update:
datastore.write(jobject, update_mtime=False,
reply_handler=self.datastore_write_cb,
error_handler=self.datastore_write_error_cb)
self.btn_save.props.sensitive = False
def datastore_write_cb(self):
pass
def datastore_write_error_cb(self, error):
logging.error(
'sugarcommander.datastore_write_error_cb:'
' %r' % error)
def close(self, skip_save=False):
"Override the close method so we don't try to
create a Journal entry."
activity.Activity.close(self, True)
def selection_journal_cb(self, selection):
self.btn_delete.props.sensitive = True
tv = selection.get_tree_view()
model = tv.get_model()
sel = selection.get_selected()
if sel:
model, iter = sel
jobject = model.get_value(iter,COLUMN_JOBJECT)
jobject = datastore.get(jobject.object_id)
self.selected_journal_entry = jobject
self.set_form_fields(jobject)
self.selected_path = model.get_path(iter)
def set_form_fields(self, jobject):
self.title_entry.set_text(jobject.metadata['title'])
description_textbuffer = \
self.description_textview.get_buffer()
if jobject.metadata.has_key('description'):
description_textbuffer.set_text(
jobject.metadata['description'])
else:
description_textbuffer.set_text('')
tags_textbuffer = self.tags_textview.get_buffer()
if jobject.metadata.has_key('tags'):
tags_textbuffer.set_text(jobject.metadata['tags'])
else:
tags_textbuffer.set_text('')
self.create_preview(jobject.object_id)
def create_preview(self, object_id):
jobject = datastore.get(object_id)
if jobject.metadata.has_key('preview'):
preview = jobject.metadata['preview']
if preview is None or preview == '' \
or preview == 'None':
if jobject.metadata['mime_type'].startswith(
'image/'):
filename = jobject.get_file_path()
self.show_image(filename)
return
if jobject.metadata['mime_type'] == \
'application/x-cbz':
filename = jobject.get_file_path()
fname = self.extract_image(filename)
self.show_image(fname)
os.remove(fname)
return
if jobject.metadata.has_key('preview') and \
len(jobject.metadata['preview']) > 4:
if jobject.metadata['preview'][1:4] == 'PNG':
preview_data = jobject.metadata['preview']
else:
import base64
preview_data = \
base64.b64decode(
jobject.metadata['preview'])
loader = gtk.gdk.PixbufLoader()
loader.write(preview_data)
scaled_buf = loader.get_pixbuf()
loader.close()
self.image.set_from_pixbuf(scaled_buf)
self.image.show()
else:
self.image.clear()
self.image.show()
def load_journal_table(self):
self.btn_save.props.sensitive = False
self.btn_delete.props.sensitive = False
ds_mounts = datastore.mounts()
mountpoint_id = None
if len(ds_mounts) == 1 and \
ds_mounts[0]['id'] == 1:
pass
else:
for mountpoint in ds_mounts:
id = mountpoint['id']
uri = mountpoint['uri']
if uri.startswith('/home'):
mountpoint_id = id
query = {}
if mountpoint_id is not None:
query['mountpoints'] = [ mountpoint_id ]
ds_objects, num_objects = \
datastore.find(query, properties=['uid',
'title', 'mime_type'])
self.ls_journal.clear()
for i in xrange (0, num_objects, 1):
iter = self.ls_journal.append()
title = ds_objects[i].metadata['title']
self.ls_journal.set(iter, COLUMN_TITLE, title)
mime = ds_objects[i].metadata['mime_type']
self.ls_journal.set(iter, COLUMN_MIME, mime)
self.ls_journal.set(iter, COLUMN_JOBJECT,
ds_objects[i])
if not self.selected_journal_entry is None and \
self.selected_journal_entry.object_id == \
ds_objects[i].object_id:
self.selection_journal.select_iter(iter)
self.ls_journal.set_sort_column_id(COLUMN_TITLE,
gtk.SORT_ASCENDING)
v_adjustment = \
self.list_scroller_journal.get_vadjustment()
v_adjustment.value = 0
return ds_objects[0]
def create_journal_entry(self, widget, data=None):
filename = self._filechooser.get_filename()
journal_entry = datastore.create()
journal_entry.metadata['title'] = \
self.make_new_filename(filename)
journal_entry.metadata['title_set_by_user'] = '1'
journal_entry.metadata['keep'] = '0'
file_mimetype = mime.get_for_file(filename)
if not file_mimetype is None:
journal_entry.metadata['mime_type'] = \
file_mimetype
journal_entry.metadata['buddies'] = ''
if file_mimetype.startswith('image/'):
preview = \
self.create_preview_metadata(filename)
elif file_mimetype == 'application/x-cbz':
fname = self.extract_image(filename)
preview = self.create_preview_metadata(fname)
os.remove(fname)
else:
preview = ''
if not preview == '':
journal_entry.metadata['preview'] = \
dbus.ByteArray(preview)
else:
journal_entry.metadata['preview'] = ''
journal_entry.file_path = filename
datastore.write(journal_entry)
self.alert(_('Success'), _('%s added to Journal.')
% self.make_new_filename(filename))
def alert(self, title, text=None):
alert = NotifyAlert(timeout=20)
alert.props.title = title
alert.props.msg = text
self.add_alert(alert)
alert.connect('response', self.alert_cancel_cb)
alert.show()
def alert_cancel_cb(self, alert, response_id):
self.remove_alert(alert)
def show_image(self, filename):
"display a resized image in a preview"
scaled_buf = gtk.gdk.pixbuf_new_from_file_at_size(
filename,
style.zoom(320), style.zoom(240))
self.image.set_from_pixbuf(scaled_buf)
self.image.show()
def extract_image(self, filename):
zf = zipfile.ZipFile(filename, 'r')
image_files = zf.namelist()
image_files.sort()
file_to_extract = image_files[0]
extract_new_filename = self.make_new_filename(
file_to_extract)
if extract_new_filename is None or \
extract_new_filename == '':
# skip over directory name if the images
# are in a subdirectory.
file_to_extract = image_files[1]
extract_new_filename = self.make_new_filename(
file_to_extract)
if len(image_files) > 0:
if self.save_extracted_file(zf, file_to_extract):
fname = os.path.join(self.get_activity_root(),
'instance',
extract_new_filename)
return fname
def save_extracted_file(self, zipfile, filename):
"Extract the file to a temp directory for viewing"
try:
filebytes = zipfile.read(filename)
except zipfile.BadZipfile, err:
print 'Error opening the zip file: %s' % (err)
return False
except KeyError, err:
self.alert('Key Error', 'Zipfile key not found: '
+ str(filename))
return
outfn = self.make_new_filename(filename)
if (outfn == ''):
return False
fname = os.path.join(self.get_activity_root(),
'instance', outfn)
f = open(fname, 'w')
try:
f.write(filebytes)
finally:
f.close()
return True
def make_new_filename(self, filename):
partition_tuple = filename.rpartition('/')
return partition_tuple[2]
def create_preview_metadata(self, filename):
file_mimetype = mime.get_for_file(filename)
if not file_mimetype.startswith('image/'):
return ''
scaled_pixbuf = \
gtk.gdk.pixbuf_new_from_file_at_size(
filename,
style.zoom(320), style.zoom(240))
preview_data = []
def save_func(buf, data):
data.append(buf)
scaled_pixbuf.save_to_callback(save_func,
'png',
user_data=preview_data)
preview_data = ''.join(preview_data)
return preview_data
Miremos este código analizando los métodos de a uno.
Agregar una entrada al Journal
Cuando alguien pulsa un botón del gtk.FileChooser se agrega una entrada al Journal. Este es el código que se ejecuta.
def create_journal_entry(self, widget, data=None):
filename = self._filechooser.get_filename()
journal_entry = datastore.create()
journal_entry.metadata['title'] = \
self.make_new_filename(
filename)
journal_entry.metadata['title_set_by_user'] = '1'
journal_entry.metadata['keep'] = '0'
file_mimetype = mime.get_for_file(filename)
if not file_mimetype is None:
journal_entry.metadata['mime_type'] = \
file_mimetype
journal_entry.metadata['buddies'] = ''
if file_mimetype.startswith('image/'):
preview = self.create_preview_metadata(filename)
elif file_mimetype == 'application/x-cbz':
fname = self.extract_image(filename)
preview = self.create_preview_metadata(fname)
os.remove(fname)
else:
preview = ''
if not preview == '':
journal_entry.metadata['preview'] = \
dbus.ByteArray(preview)
else:
journal_entry.metadata['preview'] = ''
journal_entry.file_path = filename
datastore.write(journal_entry)
La metadata es lo único que vale la pena comentar de esto, title (título) es lo que se indica como #3 en la imagen debajo. title_set_by_user (título elegido por el autor) se setea en 1 para que la Actividad no pida al usuario cambiar el título cuando se cierre. keep refiere a las estrellitas que aparecen al inicio de la entrad del Journal (ver #1 en la imagen debajo), se encienden con keep seteado en 1 y se apagan en 0. buddies es la lista de usuarios que colaboraron en esta entrada del Journal, no hay ninguno en este ejemplo pero aparecen en #4 en la imagen debajo.

preview es una imagen en formato PNG que muestra la captura de pantalla de la Actividad en uso. Esta es creada por la propia Actividad cuando se ejecuta de modo que no es necesario crearla al agregar una entrada al Diario. Simplemente se deja el string vacío ('') para esta propiedad.
Como en el Sugar Commander, las preview son mucho más visibles que en la Actividad Journal normal, decidí que Sugar Commander creara una imagen de preview para todo archivo de imagen o libros que se agregara al Journal. Para esto hice un pixbuf de la imagen para que se ajuste a la dimensiones escaladas de 320x240 pixeles y luego un dbus.ByteArray desde ahí, porque este es el formato que el Journal usa para guardar las imágenes de preview.
El mime_type describe el formato del archivo y generalmente se asigna sobre la base del sufijo del nombre de archivo. Por ejemplo, archivos terminados en .html tienen tipo MIME 'text/html'. Python tiene un paquete llamado mimetypes que a partir del nombre del archivo deduce de que tipo MIME se trata, pero Sugar tiene su propio paquete para hacer la misma cosa. Para la mayoría de los archivos es indistinto usar uno u otro pero como Sugar tiene sus propios MIME para cosas como los "bundles" (empaquetados) de las Actividades, es mejor utilizar el paquete de tipos MIME de Sugar. Puedes importarlo de esta forma:
from sugar import mime
El resto de la metadata ( ícono, hora de modificación) se crea automáticamente.
No agregar una entrada al Journal
Las Actividades Sugar crean por defecto la entrada al Diario usando el método write_file(). Pero hay algunas Actividades que no se beneficiarían al hacer esto. Por ejemplo, 'Get Internet Archive Books' (Descargar libros de Internet) descarga los e-books al Diario, pero no tiene una entrada de Diario propia. Lo mismo ocurre con el propio Sugar Commander.
Si creas un juego que registre los mejores puntajes y los guarde. Puedes guardar esos puntajes en una entrada del Journal pero eso requiere que los jugadores retomen el juego desde el Journal y no desde el anillo inicial de Actividades. Por eso tu puedes preferir guardar estos registros en un archivo en el directorio de datos y no dejar una entrada en el Journal.
Sugar te da un procedimiento para esto. Primero hay que especificar un argumento extra en método __init__() de tu Actividad de esta forma:
class SugarCommander(activity.Activity):
def __init__(self, handle, create_jobject=True):
"The entry point to the Activity"
activity.Activity.__init__(self, handle, False)
En segundo lugar hay que editar el método close() de esta manera:
def close(self, skip_save=False):
"Override the close method so we don't try to
create a Journal entry."
activity.Activity.close(self, True)
Esto es todo lo necesario para evitar la entrada en el Journal.
Listar las entradas del Journal
Si se quiere una lista de las entradas del Journal, se puede usar el método find() de datastore. El método find usa un argumento que contiene el criterio de búsqueda. Si quisieras buscar archivos de imagen podrías filtrar por mime-type usando sentencias como esta:
ds_objects, num_objects = datastore.find(
{'mime_type':['image/jpeg',
'image/gif', 'image/tiff', 'image/png']},
properties=['uid',
'title', 'mime_type']))
Podemos usar cualquier atributo de metadata como criterio de búsqueda. Para listar todo en el Diario usamos un criterio vacío como este:
ds_objects, num_objects = datastore.find({},
properties=['uid',
'title', 'mime_type']))
El argumento "properties" selecciona qué metadata se pide para cada objeto de la lista. Aunque conviene limitar esta selección siempre se debe incluir el uid. A la vez nunca se debe incluir en un listado el preview. Este es un archivo de imagen con la vista de la Actividad tal y como se veía al usarse por última vez. Hay formas simples de pedir esta imagen para una entrada puntual del Diario pero nunca es conveniente incluir este pedido en una lista porque enlentecería enormemente el funcionamiento.
Obtener un listado completo del Journal es complicado dada la reescritura que se hizo del datastore para Sugar .84. Antes de esto el método datastore.find() listaba simultáneamente las entradas al Diario y los archivos sobre medios externos, como tarjetas SD y pendrives. En .84 o posteriores sólo lista entradas de Diario. Afortunadamente es posible escribir código que soporte el comportamiento anterior. Acá el código que en Sugar Commander lista exclusivamente las entradas al Diario.
def load_journal_table(self):
self.btn_save.props.sensitive = False
self.btn_delete.props.sensitive = False
ds_mounts = datastore.mounts()
mountpoint_id = None
if len(ds_mounts) == 1 and ds_mounts[0]['id'] == 1:
pass
else:
for mountpoint in ds_mounts:
id = mountpoint['id']
uri = mountpoint['uri']
if uri.startswith('/home'):
mountpoint_id = id
query = {}
if mountpoint_id is not None:
query['mountpoints'] = [ mountpoint_id ]
ds_objects, num_objects = datastore.find(
query, properties=['uid',
'title', 'mime_type'])
self.ls_journal.clear()
for i in xrange (0, num_objects, 1):
iter = self.ls_journal.append()
title = ds_objects[i].metadata['title']
self.ls_journal.set(iter,
COLUMN_TITLE, title)
mime = ds_objects[i].metadata['mime_type']
self.ls_journal.set(iter, COLUMN_MIME, mime)
self.ls_journal.set(iter, COLUMN_JOBJECT,
ds_objects[i])
if not self.selected_journal_entry is None and \
self.selected_journal_entry.object_id == \
ds_objects[i].object_id:
self.selection_journal.select_iter(iter)
self.ls_journal.set_sort_column_id(COLUMN_TITLE,
gtk.SORT_ASCENDING)
v_adjustment = \
self.list_scroller_journal.get_vadjustment()
v_adjustment.value = 0
return ds_objects[0]
Necesitamos usar el método datastore.mounts() con doble propósito:
-
En Sugar .82 y anteriores el listado incluye todos los mount points (puntos de montaje) incluido el lugar donde se monta el Diario y los puntos para medios externos. A la vez "mountpoint" es un diccionario Python que tiene una propiedad uri (que es la ruta al punto de montaje) y una propiedad id (que es el nombre dado al punto de montaje). Cada entrada de Diario tiene el atributo mountpoint en su metadata. El uri del Journal será el único que empieza con /home, entonces para listar únicamente objetos del Diario limitamos la búsqueda a objetos donde la id del punto de montaje sea igual a la metadata mountpoint.
-
En Sugar .84 y posteriores el método datastore.mounts() existe pero no da información acerca de puntos de montaje. Sin embargo se puede usar el código pegado encima para comprobar que hay un único punto de montaje y que su id es 1. Si esto es así, es porque estamos trabajando con el datastore reescrito de .84 o posterior. Otra diferencia es que los objetos del Journal ya no tendrán en la metadata a mountpoint como clave. Si vemos el código previo, atiende esta diferencia y funciona con cualquiera de las versiones de Sugar.
¿Qué hacer si queremos el comportamiento de Sugar .82, o sea listar como objetos del Diario tanto las entradas al mismo como los archivos de un USB? Quise eso para View Slides y terminé usando este código:
def load_journal_table(self):
ds_objects, num_objects = datastore.find(
{'mime_type':['image/jpeg',
'image/gif', 'image/tiff', 'image/png']},
properties=['uid', 'title', 'mime_type'])
self.ls_right.clear()
for i in xrange (0, num_objects, 1):
iter = self.ls_right.append()
title = ds_objects[i].metadata['title']
mime_type = ds_objects[i].metadata['mime_type']
if mime_type == 'image/jpeg' \
and not title.endswith('.jpg') \
and not title.endswith('.jpeg') \
and not title.endswith('.JPG') \
and not title.endswith('.JPEG') :
title = title + '.jpg'
if mime_type == 'image/png' \
and not title.endswith('.png') \
and not title.endswith('.PNG'):
title = title + '.png'
if mime_type == 'image/gif' \
and not title.endswith('.gif')\
and not title.endswith('.GIF'):
title = title + '.gif'
if mime_type == 'image/tiff' \
and not title.endswith('.tiff')\
and not title.endswith('.TIFF'):
title = title + '.tiff'
self.ls_right.set(iter, COLUMN_IMAGE, title)
jobject_wrapper = JobjectWrapper()
jobject_wrapper.set_jobject(ds_objects[i])
self.ls_right.set(iter, COLUMN_PATH,
jobject_wrapper)
valid_endings = ('.jpg', '.jpeg', '.JPEG',
'.JPG', '.gif', '.GIF', '.tiff',
'.TIFF', '.png', '.PNG')
ds_mounts = datastore.mounts()
if len(ds_mounts) == 1 and ds_mounts[0]['id'] == 1:
# datastore.mounts() is stubbed out,
# we're running .84 or better
for dirname, dirnames, filenames in os.walk(
'/media'):
if '.olpc.store' in dirnames:
dirnames.remove('.olpc.store')
# don't visit .olpc.store directories
for filename in filenames:
if filename.endswith(valid_endings):
iter = self.ls_right.append()
jobject_wrapper = JobjectWrapper()
jobject_wrapper.set_file_path(
os.path.join(dirname, filename))
self.ls_right.set(iter, COLUMN_IMAGE,
filename)
self.ls_right.set(iter, COLUMN_PATH,
jobject_wrapper)
self.ls_right.set_sort_column_id(COLUMN_IMAGE,
gtk.SORT_ASCENDING)
En este caso utilicé el método datastore.mounts() para descubrir que versión del datastore estaba en uso y entonces si se trataba de .84 o posterior usé os.walk() para crear una lista plana de todos los archivos encontrados bajo el directorio /media (que es donde se montan los USB y las SD). No puedo transformar estos archivos en directorios, pero sí hacer una clase wrapper que abarque tanto objetos del Diario como archivos, y usar estos objetos como normalmente utilizaría objetos del Diario. Esta clase wrapper se vería asi:
class JobjectWrapper():
def __init__(self):
self.__jobject = None
self.__file_path = None
def set_jobject(self, jobject):
self.__jobject = jobject
def set_file_path(self, file_path):
self.__file_path = file_path
def get_file_path(self):
if self.__jobject != None:
return self.__jobject.get_file_path()
else:
return self.__file_path
Usar las entradas del Journal
Cuando se quiere leer una archivo guardado como objeto de Journal, se puede usar el método get_file_path() de un objeto Journal para obtener la ruta del archivo y abrirlo para lectura:
fname = jobject.get_file_path()
Una palabra de advertencia: esta ruta no existe hasta que no se llama al método get_file_path() y no existirá después. Con el Diario se trabaja sobre copias de los archivos del Diario y no sobre el original. Esta es la razón por la que no vale guardar para uso posterior la ruta obtenida mediante get_file_path() y en cambio si hay que guardar el objeto Journal y llamar al método cuando se necesite la ruta.
Las entradas de metadata del Diario son en general cadenas y trabajan de formas esperables con la excepción de preview:
def create_preview(self, object_id):
jobject = datastore.get(object_id)
if jobject.metadata.has_key('preview'):
preview = jobject.metadata['preview']
if preview is None or preview == '' or
preview == 'None':
if jobject.metadata['mime_type'].startswith(
'image/'):
filename = jobject.get_file_path()
self.show_image(filename)
return
if jobject.metadata['mime_type'] == \
'application/x-cbz':
filename = jobject.get_file_path()
fname = self.extract_image(filename)
self.show_image(fname)
os.remove(fname)
return
if jobject.metadata.has_key('preview') and \
len(jobject.metadata['preview']) > 4:
if jobject.metadata['preview'][1:4] == 'PNG':
preview_data = jobject.metadata['preview']
else:
import base64
preview_data = base64.b64decode(
jobject.metadata['preview'])
loader = gtk.gdk.PixbufLoader()
loader.write(preview_data)
scaled_buf = loader.get_pixbuf()
loader.close()
self.image.set_from_pixbuf(scaled_buf)
self.image.show()
else:
self.image.clear()
self.image.show()
El atributo preview difiere de otros de la metadata en dos maneras:
-
Nunca se debe incluir a preview como metadata cuando se pida una lista de los objetos del Journal. Necesitaríamos una copia completa del objeto del Diario para obtener esto. Como ya tenemos un objeto del Diario podemos obtener el objeto completo sólo conociendo su object id y entonces pidiendo una copia al datastore con ese id.
-
La imagen de preview es un binario (dbus.ByteArray) pero en las versiones de Sugar anteriores a .82 será guardado como una cadena de texto. Para lograr esto se codifica base 64.
El código a usar para obtener una copia entera de un objeto del Diario se ve así:
object_id = jobject.object_id
jobject = datastore.get(object_id)
Para explicar que es codificar en base 64, digamos que seguramente escuchaste que las computadoras utilizan el sistema de numeración en base dos, donde los únicos dígitos son 1 y 0. Una unidad de almacenamiento de datos que puede contener sólo un cero o un uno se llama bit. Las computadoras precisan almacenar otra información distinta de números y para esto se agrupan (generalmente) los bits de a 8 y se llama byte a esta agrupación. Si usamos 7 de los 8 bits en un byte podemos guardar un carácter del alfabeto romano, un signo de puntuación, un dígito o cosas como los caracteres que marcan tabulación o avances de línea. Todo archivo que pueda crearse usando solamente 7 de los 8 bits será un archivo de texto. Todo lo que necesite usar los 8 bits de cada byte, incluyendo programas, vídeos, música o fotos de Jessica Alba se llamará binario. En versiones anteriores a Sugar .82 la metadata de un objeto del Journal sólo podía almacenar cadenas de texto y de alguna forma había que representar 8 bits usando 7 bits. Esto se resolvió agrupando los bytes en paquetes más grandes y luego partiéndolos de nuevo en grupos de 7 bits. Python tiene el módulo base64 para hacer esto.
La codificación base 64 es actualmente una técnica muy común. Si alguna vez enviaste un adjunto en un email, este viajó codificado en base 64.
El código mostrado debajo muestra un par de formas de crear la imagen de preview. Si la metadata de preview contiene una imagen PNG esta se cargará sobre un pixbuf y se desplegará. Si el tipo MIME es el de un archivo de imagen o de un zip de imágenes, como los que usan los comics, crearemos el preview desde la misma entrada de Journal.
El código verifica el primero de los tres caracteres en la metadata preview para ver si son 'PNG'. Si es así, el archivo es un Portable Network Graphics que se guarda como binario y no necesita conversión a base 64 pero en otro caso la conversión es necesaria.
Actualizar un objeto del Journal
El código a utilizar para actualizar un objeto del Journal se ve así:
def update_entry(self):
needs_update = False
if self.selected_journal_entry is None:
return
object_id = self.selected_journal_entry.object_id
jobject = datastore.get(object_id)
old_title = jobject.metadata.get('title', None)
if old_title != self.title_entry.props.text:
jobject.metadata['title'] = \
self.title_entry.props.text
jobject.metadata['title_set_by_user'] = '1'
needs_update = True
old_tags = jobject.metadata.get('tags', None)
new_tags = \
self.tags_textview.props.buffer.props.text
if old_tags != new_tags:
jobject.metadata['tags'] = new_tags
needs_update = True
old_description = \
jobject.metadata.get('description', None)
new_description = \
self.description_textview.props.buffer.props.text
if old_description != new_description:
jobject.metadata['description'] = \
new_description
needs_update = True
if needs_update:
datastore.write(jobject, update_mtime=False,
reply_handler=self.datastore_write_cb,
error_handler=self.datastore_write_error_cb)
self.btn_save.props.sensitive = False
def datastore_write_cb(self):
pass
def datastore_write_error_cb(self, error):
logging.error(
'sugarcommander.datastore_write_error_cb:'
' %r' % error)
Borrar una entrada del Diario
El código para borrar una entrada del Diario es este:
def delete_button_press_event_cb(self, entry, event):
datastore.delete(
self.selected_journal_entry.object_id)
Obtener retro-llamadas (callbacks) desde el Journal usando D-Bus
En el capítulo Hacer Actividades compartidas vimos como llamadas de D-Bus enviadas sobre Telepathy podían usarse para mandar mensajes desde una Actividad que se ejecuta en una computadora a la misma Actividad en una computadora distinta. Normalmente no se usa D-bus de esta forma sino para enviar mensajes entre programas que se ejecutan en la misma máquina.
Por ejemplo, al trabajar con el Diario se obtienen retro-llamadas cada vez que el Diario se actualiza. También se generan retro-llamadas cuando la propia actividad es la que se refresca. Si es importante para tu Actividad saber si el Diario se actualizó o no, debieras obtener estas retro-llamadas.
Lo primero que debes hacer es definir algunas constantes e importar el paquete D_Bus:
DS_DBUS_SERVICE = 'org.laptop.sugar.DataStore' DS_DBUS_INTERFACE = 'org.laptop.sugar.DataStore' DS_DBUS_PATH = '/org/laptop/sugar/DataStore' import dbus
Luego, en tu método __init__() pon código para conectar las señales y hacer las retro-llamadas:
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',
self._datastore_created_cb)
_datastore.connect_to_signal('Updated',
self._datastore_updated_cb)
_datastore.connect_to_signal('Deleted',
self._datastore_deleted_cb)
Los métodos llamados por las retro-llamadas pueden ser algo así:
def datastore_created_cb(self, uid):
new_jobject = datastore.get(uid)
iter = self.ls_journal.append()
title = new_jobject.metadata['title']
self.ls_journal.set(iter,
COLUMN_TITLE, title)
mime = new_jobject.metadata['mime_type']
self.ls_journal.set(iter,
COLUMN_MIME, mime)
self.ls_journal.set(iter,
COLUMN_JOBJECT, new_jobject)
def datastore_updated_cb(self, uid):
new_jobject = datastore.get(uid)
iter = self.ls_journal.get_iter_first()
for row in self.ls_journal:
jobject = row[COLUMN_JOBJECT]
if jobject.object_id == uid:
title = new_jobject.metadata['title']
self.ls_journal.set_value(iter,
COLUMN_TITLE, title)
break
iter = self.ls_journal.iter_next(iter)
object_id = \
self.selected_journal_entry.object_id
if object_id == uid:
self.set_form_fields(new_jobject)
def datastore_deleted_cb(self, uid):
save_path = self.selected_path
iter = self.ls_journal.get_iter_first()
for row in self.ls_journal:
jobject = row[COLUMN_JOBJECT]
if jobject.object_id == uid:
self.ls_journal.remove(iter)
break
iter = self.ls_journal.iter_next(iter)
try:
self.selection_journal.select_path(
save_path)
self.tv_journal.grab_focus()
except:
self.title_entry.set_text('')
description_textbuffer = \
self.description_textview.get_buffer()
description_textbuffer.set_text('')
tags_textbuffer = \
self.tags_textview.get_buffer()
tags_textbuffer.set_text('')
self.btn_save.props.sensitive = False
self.btn_delete.props.sensitive = False
self.image.clear()
self.image.show()
El uid que se asigna a cada retro-llamada es el id del objeto del Diario -object id que fue agregado, actualizado o borrado. Si se agrega una entrada al Diario obtenemos el objeto Journal desde su uid, y luego lo agregamos al gtk.ListStore para armar el gtk.TreeModel donde listamos las entradas según el modelo árbol. Necesitamos llevar control cuando una entrada se actualiza o se borra para esto usamos el uid para descubrir cual renglón de la lista gtk.ListStore es necesario borrar o modificar. Para esto se itera sobre las entradas en la gtk.ListStore buscando coincidencias.
Ahora ya sabes todo lo que puedes necesitar para trabajar con el Journal.
2




