/**
* bonobo-config-xmldb.c: xml configuration database implementation.
*
* Author:
* Dietmar Maurer (dietmar@ximian.com)
*
* Copyright 2000 Ximian, Inc.
*/
#include <config.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <bonobo/bonobo-arg.h>
#include <bonobo/bonobo-property-bag-xml.h>
#include <bonobo/bonobo-moniker-util.h>
#include <bonobo/bonobo-exception.h>
#include <bonobo-conf/bonobo-config-utils.h>
#include <gnome-xml/xmlmemory.h>
#include <gtk/gtkmain.h>
#include "bonobo-config-xmldb.h"
static GtkObjectClass *parent_class = NULL;
#define CLASS(o) BONOBO_CONFIG_XMLDB_CLASS (GTK_OBJECT(o)->klass)
#define PARENT_TYPE BONOBO_CONFIG_DATABASE_TYPE
#define FLUSH_INTERVAL 30 /* 30 seconds */
static DirEntry *
dir_lookup_entry (DirData *dir,
char *name,
gboolean create)
{
GSList *l;
DirEntry *de;
l = dir->entries;
while (l) {
de = (DirEntry *)l->data;
if (!strcmp (de->name, name))
return de;
l = l->next;
}
if (create) {
de = g_new0 (DirEntry, 1);
de->dir = dir;
de->name = g_strdup (name);
dir->entries = g_slist_prepend (dir->entries, de);
return de;
}
return NULL;
}
static DirData *
dir_lookup_subdir (DirData *dir,
char *name,
gboolean create)
{
GSList *l;
DirData *dd;
l = dir->subdirs;
while (l) {
dd = (DirData *)l->data;
if (!strcmp (dd->name, name))
return dd;
l = l->next;
}
if (create) {
dd = g_new0 (DirData, 1);
dd->dir = dir;
dd->name = g_strdup (name);
dir->subdirs = g_slist_prepend (dir->subdirs, dd);
return dd;
}
return NULL;
}
static DirData *
lookup_dir (DirData *dir,
const char *path,
gboolean create)
{
const char *s, *e;
char *name;
DirData *dd = dir;
s = path;
while (*s == '/') s++;
if (*s == '\0')
return dir;
if ((e = strchr (s, '/')))
name = g_strndup (s, e - s);
else
name = g_strdup (s);
if ((dd = dir_lookup_subdir (dd, name, create))) {
if (e)
dd = lookup_dir (dd, e, create);
g_free (name);
return dd;
}
return NULL;
}
static DirEntry *
lookup_dir_entry (BonoboConfigXMLDB *xmldb,
const char *key,
gboolean create)
{
char *dir_name;
char *leaf_name;
DirEntry *de;
DirData *dd;
if ((dir_name = bonobo_config_dir_name (key))) {
dd = lookup_dir (xmldb->dir, dir_name, create);
if (dd && !dd->node) {
dd->node = xmlNewChild (xmldb->doc->root, NULL,
"section", NULL);
xmlSetProp (dd->node, "path", dir_name);
}
g_free (dir_name);
} else {
dd = xmldb->dir;
if (!dd->node)
dd->node = xmlNewChild (xmldb->doc->root, NULL,
"section", NULL);
}
if (!dd)
return NULL;
if (!(leaf_name = bonobo_config_leaf_name (key)))
return NULL;
de = dir_lookup_entry (dd, leaf_name, create);
g_free (leaf_name);
return de;
}
static CORBA_any *
real_get_value (BonoboConfigDatabase *db,
const CORBA_char *key,
const CORBA_char *locale,
CORBA_Environment *ev)
{
BonoboConfigXMLDB *xmldb = BONOBO_CONFIG_XMLDB (db);
DirEntry *de;
CORBA_any *value = NULL;
de = lookup_dir_entry (xmldb, key, FALSE);
if (!de) {
bonobo_exception_set (ev, ex_Bonobo_ConfigDatabase_NotFound);
return NULL;
}
if (de->value)
return bonobo_arg_copy (de->value);
/* localized value */
if (de->node && de->node->childs && de->node->childs->next)
value = bonobo_config_xml_decode_any ((BonoboUINode *)de->node,
locale, ev);
if (!value)
bonobo_exception_set (ev, ex_Bonobo_ConfigDatabase_NotFound);
return value;
}
static void xmlNodeDump (xmlBufferPtr buf, xmlDocPtr doc, xmlNodePtr cur, int level, int format);
static void
xmlAttrDump (xmlBufferPtr buf, xmlDocPtr doc, xmlAttrPtr cur)
{
xmlChar *value;
if (cur == NULL) {
#ifdef DEBUG_TREE
fprintf(stderr, "xmlAttrDump : property == NULL\n");
#endif
return;
}
xmlBufferWriteChar (buf, " ");
if ((cur->ns != NULL) && (cur->ns->prefix != NULL)) {
xmlBufferWriteCHAR (buf, cur->ns->prefix);
xmlBufferWriteChar (buf, ":");
}
xmlBufferWriteCHAR (buf, cur->name);
value = xmlNodeListGetString (doc, cur->val, 0);
if (value) {
xmlBufferWriteChar (buf, "=");
xmlBufferWriteQuotedString (buf, value);
xmlFree (value);
} else {
xmlBufferWriteChar (buf, "=\"\"");
}
}
static void
xmlAttrListDump (xmlBufferPtr buf, xmlDocPtr doc, xmlAttrPtr cur)
{
if (cur == NULL) {
#ifdef DEBUG_TREE
fprintf(stderr, "xmlAttrListDump : property == NULL\n");
#endif
return;
}
while (cur != NULL) {
xmlAttrDump (buf, doc, cur);
cur = cur->next;
}
}
static void
xmlNodeListDump (xmlBufferPtr buf, xmlDocPtr doc, xmlNodePtr cur, int level, int format)
{
int i;
if (cur == NULL) {
#ifdef DEBUG_TREE
fprintf(stderr, "xmlNodeListDump : node == NULL\n");
#endif
return;
}
while (cur != NULL) {
if ((format) && (xmlIndentTreeOutput) &&
(cur->type == XML_ELEMENT_NODE))
for (i = 0; i < level; i++)
xmlBufferWriteChar (buf, " ");
xmlNodeDump (buf, doc, cur, level, format);
if (format) {
xmlBufferWriteChar (buf, "\n");
}
cur = cur->next;
}
}
static void
xmlNodeDump (xmlBufferPtr buf, xmlDocPtr doc, xmlNodePtr cur, int level, int format)
{
int i;
xmlNodePtr tmp;
if (cur == NULL) {
#ifdef DEBUG_TREE
fprintf(stderr, "xmlNodeDump : node == NULL\n");
#endif
return;
}
if (cur->type == XML_TEXT_NODE) {
if (cur->content != NULL) {
xmlChar *buffer;
#ifndef XML_USE_BUFFER_CONTENT
buffer = xmlEncodeEntitiesReentrant (doc, cur->content);
#else
buffer = xmlEncodeEntitiesReentrant (doc, xmlBufferContent (cur->content));
#endif
if (buffer != NULL) {
xmlBufferWriteCHAR (buf, buffer);
xmlFree (buffer);
}
}
return;
}
if (cur->type == XML_PI_NODE) {
if (cur->content != NULL) {
xmlBufferWriteChar (buf, "<?");
xmlBufferWriteCHAR (buf, cur->name);
if (cur->content != NULL) {
xmlBufferWriteChar (buf, " ");
#ifndef XML_USE_BUFFER_CONTENT
xmlBufferWriteCHAR (buf, cur->content);
#else
xmlBufferWriteCHAR (buf, xmlBufferContent (cur->content));
#endif
}
xmlBufferWriteChar (buf, "?>");
}
return;
}
if (cur->type == XML_COMMENT_NODE) {
if (cur->content != NULL) {
xmlBufferWriteChar (buf, "<!--");
#ifndef XML_USE_BUFFER_CONTENT
xmlBufferWriteCHAR (buf, cur->content);
#else
xmlBufferWriteCHAR (buf, xmlBufferContent (cur->content));
#endif
xmlBufferWriteChar (buf, "-->");
}
return;
}
if (cur->type == XML_ENTITY_REF_NODE) {
xmlBufferWriteChar (buf, "&");
xmlBufferWriteCHAR (buf, cur->name);
xmlBufferWriteChar (buf, ";");
return;
}
if (cur->type == XML_CDATA_SECTION_NODE) {
xmlBufferWriteChar (buf, "<![CDATA[");
if (cur->content != NULL)
#ifndef XML_USE_BUFFER_CONTENT
xmlBufferWriteCHAR (buf, cur->content);
#else
xmlBufferWriteCHAR (buf, xmlBufferContent(cur->content));
#endif
xmlBufferWriteChar (buf, "]]>");
return;
}
if (format == 1) {
tmp = cur->childs;
while (tmp != NULL) {
if ((tmp->type == XML_TEXT_NODE) ||
(tmp->type == XML_ENTITY_REF_NODE)) {
format = 0;
break;
}
tmp = tmp->next;
}
}
xmlBufferWriteChar (buf, "<");
if ((cur->ns != NULL) && (cur->ns->prefix != NULL)) {
xmlBufferWriteCHAR (buf, cur->ns->prefix);
xmlBufferWriteChar (buf, ":");
}
xmlBufferWriteCHAR (buf, cur->name);
if (cur->properties != NULL)
xmlAttrListDump (buf, doc, cur->properties);
if ((cur->content == NULL) && (cur->childs == NULL) &&
(!xmlSaveNoEmptyTags)) {
xmlBufferWriteChar (buf, "/>");
return;
}
xmlBufferWriteChar (buf, ">");
if (cur->content != NULL) {
xmlChar *buffer;
#ifndef XML_USE_BUFFER_CONTENT
buffer = xmlEncodeEntitiesReentrant (doc, cur->content);
#else
buffer = xmlEncodeEntitiesReentrant (doc, xmlBufferContent (cur->content));
#endif
if (buffer != NULL) {
xmlBufferWriteCHAR (buf, buffer);
xmlFree (buffer);
}
}
if (cur->childs != NULL) {
if (format)
xmlBufferWriteChar (buf, "\n");
xmlNodeListDump (buf, doc, cur->childs, (level >= 0 ? level + 1 : -1), format);
if ((xmlIndentTreeOutput) && (format))
for (i = 0; i < level; i++)
xmlBufferWriteChar (buf, " ");
}
xmlBufferWriteChar (buf, "</");
if ((cur->ns != NULL) && (cur->ns->prefix != NULL)) {
xmlBufferWriteCHAR (buf, cur->ns->prefix);
xmlBufferWriteChar (buf, ":");
}
xmlBufferWriteCHAR (buf, cur->name);
xmlBufferWriteChar (buf, ">");
}
static void
xmlDocContentDump (xmlBufferPtr buf, xmlDocPtr cur)
{
xmlBufferWriteChar (buf, "<?xml version=");
if (cur->version != NULL)
xmlBufferWriteQuotedString (buf, cur->version);
else
xmlBufferWriteChar (buf, "\"1.0\"");
if ((cur->encoding != NULL) &&
(strcasecmp (cur->encoding, "UTF-8") != 0)) {
xmlBufferWriteChar (buf, " encoding=");
xmlBufferWriteQuotedString (buf, cur->encoding);
}
switch (cur->standalone) {
case 1:
xmlBufferWriteChar (buf, " standalone=\"yes\"");
break;
}
xmlBufferWriteChar (buf, "?>\n");
if (cur->root != NULL) {
xmlNodePtr child = cur->root;
while (child != NULL) {
xmlNodeDump (buf, cur, child, 0, 1);
xmlBufferWriteChar (buf, "\n");
child = child->next;
}
}
}
static void
real_sync (BonoboConfigDatabase *db,
CORBA_Environment *ev)
{
BonoboConfigXMLDB *xmldb = BONOBO_CONFIG_XMLDB (db);
size_t n, written = 0;
xmlBufferPtr buf;
char *tmp_name;
ssize_t w;
int fd;
if (!db->writeable)
return;
retry_open:
tmp_name = g_strdup_printf ("%s.tmp.%d.XXXXXX", xmldb->filename, getpid ());
if (!mktemp (tmp_name)) {
g_free (tmp_name);
return;
}
fd = open (tmp_name, O_WRONLY | O_CREAT | O_EXCL, 0600);
if (fd == -1) {
g_free (tmp_name);
if (errno == EEXIST)
goto retry_open;
return;
}
if (!(buf = xmlBufferCreate ())) {
close (fd);
unlink (tmp_name);
g_free (tmp_name);
return;
}
xmlDocContentDump (buf, xmldb->doc);
n = buf->use;
do {
do {
w = write (fd, buf->content + written, n - written);
} while (w == -1 && errno == EINTR);
if (w > 0)
written += w;
} while (w != -1 && written < n);
xmlBufferFree (buf);
if (written < n || fsync (fd) == -1) {
close (fd);
unlink (tmp_name);
g_free (tmp_name);
return;
}
close (fd);
if (rename (tmp_name, xmldb->filename) < 0) {
/* if we don't have permissions then we can assume the db is read-only */
if (errno == EACCES || errno == EPERM)
db->writeable = FALSE;
unlink (tmp_name);
}
g_free (tmp_name);
}
static gint
timeout_cb (gpointer data)
{
BonoboConfigXMLDB *xmldb = BONOBO_CONFIG_XMLDB (data);
CORBA_Environment ev;
CORBA_exception_init(&ev);
real_sync (BONOBO_CONFIG_DATABASE (data), &ev);
CORBA_exception_free (&ev);
xmldb->time_id = 0;
/* remove the timeout */
return 0;
}
static void
notify_listeners (BonoboConfigXMLDB *xmldb,
const char *key,
const CORBA_any *value)
{
CORBA_Environment ev;
char *dir_name;
char *leaf_name;
char *ename;
if (!key)
return;
CORBA_exception_init(&ev);
ename = g_strconcat ("Bonobo/Property:change:", key, NULL);
bonobo_event_source_notify_listeners(xmldb->es, ename, value, &ev);
g_free (ename);
if (!(dir_name = bonobo_config_dir_name (key)))
dir_name = g_strdup ("");
if (!(leaf_name = bonobo_config_leaf_name (key)))
leaf_name = g_strdup ("");
ename = g_strconcat ("Bonobo/ConfigDatabase:change", dir_name, ":",
leaf_name, NULL);
bonobo_event_source_notify_listeners (xmldb->es, ename, value, &ev);
CORBA_exception_free (&ev);
g_free (ename);
g_free (dir_name);
g_free (leaf_name);
}
static void
real_set_value (BonoboConfigDatabase *db,
const CORBA_char *key,
const CORBA_any *value,
CORBA_Environment *ev)
{
BonoboConfigXMLDB *xmldb = BONOBO_CONFIG_XMLDB (db);
DirEntry *de;
char *name;
de = lookup_dir_entry (xmldb, key, TRUE);
if (!de) {
bonobo_exception_set (ev, ex_Bonobo_ConfigDatabase_NotFound);
return;
}
if (de->value)
CORBA_free (de->value);
de->value = bonobo_arg_copy (value);
if (de->node) {
xmlUnlinkNode (de->node);
xmlFreeNode (de->node);
}
name = bonobo_config_leaf_name (key);
de->node = (xmlNodePtr) bonobo_config_xml_encode_any (value, name, ev);
g_free (name);
bonobo_ui_node_add_child ((BonoboUINode *)de->dir->node,
(BonoboUINode *)de->node);
if (!xmldb->time_id)
xmldb->time_id = gtk_timeout_add (FLUSH_INTERVAL * 1000,
(GtkFunction)timeout_cb,
xmldb);
notify_listeners (xmldb, key, value);
}
static Bonobo_KeyList *
real_list_dirs (BonoboConfigDatabase *db,
const CORBA_char *dir,
CORBA_Environment *ev)
{
BonoboConfigXMLDB *xmldb = BONOBO_CONFIG_XMLDB (db);
Bonobo_KeyList *key_list;
DirData *dd, *sub;
GSList *l;
int len;
key_list = Bonobo_KeyList__alloc ();
key_list->_length = 0;
if (!(dd = lookup_dir (xmldb->dir, dir, FALSE)))
return key_list;
if (!(len = g_slist_length (dd->subdirs)))
return key_list;
key_list->_buffer = CORBA_sequence_CORBA_string_allocbuf (len);
CORBA_sequence_set_release (key_list, TRUE);
for (l = dd->subdirs; l != NULL; l = l->next) {
sub = (DirData *)l->data;
key_list->_buffer [key_list->_length] =
CORBA_string_dup (sub->name);
key_list->_length++;
}
return key_list;
}
static Bonobo_KeyList *
real_list_keys (BonoboConfigDatabase *db,
const CORBA_char *dir,
CORBA_Environment *ev)
{
BonoboConfigXMLDB *xmldb = BONOBO_CONFIG_XMLDB (db);
Bonobo_KeyList *key_list;
DirData *dd;
DirEntry *de;
GSList *l;
int len;
key_list = Bonobo_KeyList__alloc ();
key_list->_length = 0;
if (!(dd = lookup_dir (xmldb->dir, dir, FALSE)))
return key_list;
if (!(len = g_slist_length (dd->entries)))
return key_list;
key_list->_buffer = CORBA_sequence_CORBA_string_allocbuf (len);
CORBA_sequence_set_release (key_list, TRUE);
for (l = dd->entries; l != NULL; l = l->next) {
de = (DirEntry *)l->data;
key_list->_buffer [key_list->_length] =
CORBA_string_dup (de->name);
key_list->_length++;
}
return key_list;
}
static CORBA_boolean
real_dir_exists (BonoboConfigDatabase *db,
const CORBA_char *dir,
CORBA_Environment *ev)
{
BonoboConfigXMLDB *xmldb = BONOBO_CONFIG_XMLDB (db);
if (lookup_dir (xmldb->dir, dir, FALSE))
return TRUE;
return FALSE;
}
static void
delete_dir_entry (DirEntry *de)
{
CORBA_free (de->value);
if (de->node) {
xmlUnlinkNode (de->node);
xmlFreeNode (de->node);
}
g_free (de->name);
g_free (de);
}
static void
real_remove_value (BonoboConfigDatabase *db,
const CORBA_char *key,
CORBA_Environment *ev)
{
BonoboConfigXMLDB *xmldb = BONOBO_CONFIG_XMLDB (db);
DirEntry *de;
if (!(de = lookup_dir_entry (xmldb, key, FALSE)))
return;
de->dir->entries = g_slist_remove (de->dir->entries, de);
delete_dir_entry (de);
}
static void
delete_dir_data (DirData *dd, gboolean is_root)
{
GSList *l;
for (l = dd->subdirs; l; l = l->next)
delete_dir_data ((DirData *)l->data, FALSE);
g_slist_free (dd->subdirs);
dd->subdirs = NULL;
for (l = dd->entries; l; l = l->next)
delete_dir_entry ((DirEntry *)l->data);
g_slist_free (dd->entries);
dd->entries = NULL;
if (!is_root) {
g_free (dd->name);
if (dd->node) {
xmlUnlinkNode (dd->node);
xmlFreeNode (dd->node);
}
g_free (dd);
}
}
static void
real_remove_dir (BonoboConfigDatabase *db,
const CORBA_char *dir,
CORBA_Environment *ev)
{
BonoboConfigXMLDB *xmldb = BONOBO_CONFIG_XMLDB (db);
DirData *dd;
if (!(dd = lookup_dir (xmldb->dir, dir, FALSE)))
return;
if (dd != xmldb->dir && dd->dir)
dd->dir->subdirs = g_slist_remove (dd->dir->subdirs, dd);
delete_dir_data (dd, dd == xmldb->dir);
}
static void
bonobo_config_xmldb_destroy (GtkObject *object)
{
BonoboConfigXMLDB *xmldb = BONOBO_CONFIG_XMLDB (object);
CORBA_Environment ev;
CORBA_exception_init (&ev);
bonobo_url_unregister ("BONOBO_CONF:XLMDB", xmldb->filename, &ev);
CORBA_exception_free (&ev);
if (xmldb->doc)
xmlFreeDoc (xmldb->doc);
xmldb->doc = NULL;
g_free (xmldb->filename);
xmldb->filename = NULL;
parent_class->destroy (object);
}
static void
bonobo_config_xmldb_class_init (BonoboConfigDatabaseClass *class)
{
GtkObjectClass *object_class = (GtkObjectClass *) class;
BonoboConfigDatabaseClass *cd_class;
parent_class = gtk_type_class (PARENT_TYPE);
object_class->destroy = bonobo_config_xmldb_destroy;
cd_class = BONOBO_CONFIG_DATABASE_CLASS (class);
cd_class->get_value = real_get_value;
cd_class->set_value = real_set_value;
cd_class->list_dirs = real_list_dirs;
cd_class->list_keys = real_list_keys;
cd_class->dir_exists = real_dir_exists;
cd_class->remove_value = real_remove_value;
cd_class->remove_dir = real_remove_dir;
cd_class->sync = real_sync;
}
static void
bonobo_config_xmldb_init (BonoboConfigXMLDB *xmldb)
{
xmldb->dir = g_new0 (DirData, 1);
}
BONOBO_X_TYPE_FUNC (BonoboConfigXMLDB, PARENT_TYPE, bonobo_config_xmldb);
static void
read_section (DirData *dd)
{
CORBA_Environment ev;
DirEntry *de;
xmlNodePtr s;
gchar *name;
CORBA_exception_init (&ev);
s = dd->node->childs;
while (s) {
if (s->type == XML_ELEMENT_NODE &&
!strcmp (s->name, "entry") &&
(name = xmlGetProp(s, "name"))) {
de = dir_lookup_entry (dd, name, TRUE);
de->node = s;
/* we only decode it if it is a single value,
* multiple (localized) values are decoded in the
* get_value function */
if (!(s->childs && s->childs->next))
de->value = bonobo_config_xml_decode_any
((BonoboUINode *)s, NULL, &ev);
xmlFree (name);
}
s = s->next;
}
CORBA_exception_free (&ev);
}
static void
fill_cache (BonoboConfigXMLDB *xmldb)
{
xmlNodePtr n;
gchar *path;
DirData *dd;
n = xmldb->doc->root->childs;
while (n) {
if (n->type == XML_ELEMENT_NODE &&
!strcmp (n->name, "section")) {
path = xmlGetProp(n, "path");
if (!path || !strcmp (path, "")) {
dd = xmldb->dir;
} else
dd = lookup_dir (xmldb->dir, path, TRUE);
if (!dd->node)
dd->node = n;
read_section (dd);
xmlFree (path);
}
n = n->next;
}
}
Bonobo_ConfigDatabase
bonobo_config_xmldb_new (const char *filename)
{
BonoboConfigXMLDB *xmldb;
Bonobo_ConfigDatabase db;
CORBA_Environment ev;
char *real_name;
g_return_val_if_fail (filename != NULL, NULL);
CORBA_exception_init (&ev);
if (filename [0] == '~' && filename [1] == '/')
real_name = g_strconcat (g_get_home_dir (), &filename [1],
NULL);
else
real_name = g_strdup (filename);
db = bonobo_url_lookup ("BONOBO_CONF:XLMDB", real_name, &ev);
if (BONOBO_EX (&ev)) {
CORBA_exception_init (&ev);
db = CORBA_OBJECT_NIL;
}
if (db) {
g_free (real_name);
CORBA_exception_free (&ev);
return bonobo_object_dup_ref (db, NULL);
}
if (!(xmldb = gtk_type_new (BONOBO_CONFIG_XMLDB_TYPE))) {
g_free (real_name);
CORBA_exception_free (&ev);
return CORBA_OBJECT_NIL;
}
xmldb->filename = real_name;
BONOBO_CONFIG_DATABASE (xmldb)->writeable = TRUE;
xmldb->doc = xmlParseFile (xmldb->filename);
if (!xmldb->doc)
xmldb->doc = xmlNewDoc("1.0");
if (xmldb->doc->root == NULL)
xmldb->doc->root = xmlNewDocNode (xmldb->doc, NULL,
"bonobo-config", NULL);
if (strcmp (xmldb->doc->root->name, "bonobo-config")) {
xmlFreeDoc (xmldb->doc);
xmldb->doc = xmlNewDoc("1.0");
xmldb->doc->root = xmlNewDocNode (xmldb->doc, NULL,
"bonobo-config", NULL);
}
fill_cache (xmldb);
xmldb->es = bonobo_event_source_new ();
bonobo_object_add_interface (BONOBO_OBJECT (xmldb),
BONOBO_OBJECT (xmldb->es));
db = CORBA_Object_duplicate (BONOBO_OBJREF (xmldb), NULL);
bonobo_url_register ("BONOBO_CONF:XLMDB", real_name, NULL, db, &ev);
CORBA_exception_free (&ev);
return db;
}
syntax highlighted by Code2HTML, v. 0.9.1