[Python-checkins] r52625 - in tracker/vendor/roundup/current: .cvsignore BUILD.txt CHANGES.txt COPYING.txt ChangeLog MANIFEST.in README.txt cgi-bin cgi-bin/roundup.cgi demo.py detectors detectors/creator_resolution.py detectors/emailauditor.py detectors/newissuecopy.py doc doc/.cvsignore doc/FAQ.txt doc/Makefile doc/ZPL.txt doc/admin_guide.txt doc/announcement.txt doc/customizing.txt doc/debugging.txt doc/default.css doc/design.txt doc/developers.txt doc/features.txt doc/glossary.txt doc/images doc/images/edit.png doc/images/hyperdb.png doc/images/logo-acl-medium.png doc/images/logo-codesourcery-medium.png doc/images/logo-software-carpentry-standard.png doc/images/roundup-1.png doc/images/roundup.png doc/implementation.txt doc/index.txt doc/installation.txt doc/mysql.txt doc/original_overview.html doc/overview.txt doc/postgresql.txt doc/roundup-admin.1 doc/roundup-demo.1 doc/roundup-favicon.ico doc/roundup-mailgw.1 doc/roundup-server.1 doc/roundup-server.ini.example doc/spec.html doc/tracker_templates.txt doc/upgrading.txt doc/user_guide.txt doc/whatsnew-0.7.txt doc/whatsnew-0.8.txt frontends frontends/README.txt frontends/ZRoundup frontends/ZRoundup/.cvsignore frontends/ZRoundup/ZRoundup.py frontends/ZRoundup/__init__.py frontends/ZRoundup/dtml frontends/ZRoundup/dtml/manage_addZRoundupForm.dtml frontends/ZRoundup/icons frontends/ZRoundup/icons/tick_symbol.gif frontends/ZRoundup/refresh.txt locale locale/.cvsignore locale/GNUmakefile locale/de.po locale/en.po locale/es_AR.po locale/fr.po locale/lt.po locale/roundup.pot locale/ru.po locale/zh_CN.po locale/zh_TW.po patches patches/20020205.alternate_auth roundup roundup/.cvsignore roundup/__init__.py roundup/admin.py roundup/backends roundup/backends/.cvsignore roundup/backends/__init__.py roundup/backends/back_anydbm.py roundup/backends/back_metakit.py roundup/backends/back_mysql.py roundup/backends/back_postgresql.py roundup/backends/back_sqlite.py roundup/backends/back_tsearch2.py roundup/backends/blobfiles.py roundup/backends/indexer_common.py roundup/backends/indexer_dbm.py roundup/backends/indexer_rdbms.py roundup/backends/indexer_xapian.py roundup/backends/locking.py roundup/backends/portalocker.py roundup/backends/rdbms_common.py roundup/backends/sessions_dbm.py roundup/backends/sessions_rdbms.py roundup/backends/tsearch2_setup.py roundup/cgi roundup/cgi/.cvsignore roundup/cgi/MultiMapping.py roundup/cgi/PageTemplates roundup/cgi/PageTemplates/.cvsignore roundup/cgi/PageTemplates/Expressions.py roundup/cgi/PageTemplates/GlobalTranslationService.py roundup/cgi/PageTemplates/MultiMapping.py roundup/cgi/PageTemplates/PageTemplate.py roundup/cgi/PageTemplates/PathIterator.py roundup/cgi/PageTemplates/PythonExpr.py roundup/cgi/PageTemplates/README.txt roundup/cgi/PageTemplates/TALES.py roundup/cgi/PageTemplates/__init__.py roundup/cgi/TAL roundup/cgi/TAL/.cvsignore roundup/cgi/TAL/DummyEngine.py roundup/cgi/TAL/HTMLParser.py roundup/cgi/TAL/HTMLTALParser.py roundup/cgi/TAL/README.txt roundup/cgi/TAL/TALDefs.py roundup/cgi/TAL/TALGenerator.py roundup/cgi/TAL/TALInterpreter.py roundup/cgi/TAL/TALParser.py roundup/cgi/TAL/TranslationContext.py roundup/cgi/TAL/XMLParser.py roundup/cgi/TAL/__init__.py roundup/cgi/TAL/markupbase.py roundup/cgi/TAL/talgettext.py roundup/cgi/TranslationService.py roundup/cgi/ZTUtils roundup/cgi/ZTUtils/.cvsignore roundup/cgi/ZTUtils/Batch.py roundup/cgi/ZTUtils/Iterator.py roundup/cgi/ZTUtils/__init__.py roundup/cgi/__init__.py roundup/cgi/accept_language.py roundup/cgi/actions.py roundup/cgi/apache.py roundup/cgi/cgitb.py roundup/cgi/client.py roundup/cgi/exceptions.py roundup/cgi/form_parser.py roundup/cgi/templating.py roundup/cgi/zLOG.py roundup/configuration.py roundup/date.py roundup/exceptions.py roundup/hyperdb.py roundup/i18n.py roundup/init.py roundup/install_util.py roundup/instance.py roundup/mailer.py roundup/mailgw.py roundup/msgfmt.py roundup/password.py roundup/rfc2822.py roundup/roundupdb.py roundup/scripts roundup/scripts/.cvsignore roundup/scripts/__init__.py roundup/scripts/roundup_admin.py roundup/scripts/roundup_demo.py roundup/scripts/roundup_gettext.py roundup/scripts/roundup_mailgw.py roundup/scripts/roundup_server.py roundup/security.py roundup/support.py roundup/token.py roundup/version_check.py run_tests.py scripts scripts/README.txt scripts/add-issue scripts/copy-user.py scripts/hyperdb_example.py scripts/imapServer.py scripts/import_sf.py scripts/roundup-reminder scripts/roundup.rc-debian scripts/schema_diagram.py scripts/server-ctl setup.py templates templates/classic templates/classic/.cvsignore templates/classic/TEMPLATE-INFO.txt templates/classic/detectors templates/classic/detectors/.cvsignore templates/classic/detectors/messagesummary.py templates/classic/detectors/nosyreaction.py templates/classic/detectors/statusauditor.py templates/classic/detectors/userauditor.py templates/classic/extensions templates/classic/extensions/README.txt templates/classic/html templates/classic/html/_generic.calendar.html templates/classic/html/_generic.collision.html templates/classic/html/_generic.help.html templates/classic/html/_generic.index.html templates/classic/html/_generic.item.html templates/classic/html/file.index.html templates/classic/html/file.item.html templates/classic/html/help_controls.js templates/classic/html/home.classlist.html templates/classic/html/home.html templates/classic/html/issue.index.html templates/classic/html/issue.item.html templates/classic/html/issue.search.html templates/classic/html/keyword.item.html templates/classic/html/msg.index.html templates/classic/html/msg.item.html templates/classic/html/page.html templates/classic/html/query.edit.html templates/classic/html/query.item.html templates/classic/html/style.css templates/classic/html/user.forgotten.html templates/classic/html/user.index.html templates/classic/html/user.item.html templates/classic/html/user.register.html templates/classic/html/user.rego_progress.html templates/classic/initial_data.py templates/classic/schema.py templates/minimal templates/minimal/.cvsignore templates/minimal/TEMPLATE-INFO.txt templates/minimal/detectors templates/minimal/detectors/.cvsignore templates/minimal/detectors/userauditor.py templates/minimal/extensions templates/minimal/extensions/README.txt templates/minimal/html templates/minimal/html/_generic.calendar.html templates/minimal/html/_generic.collision.html templates/minimal/html/_generic.help.html templates/minimal/html/_generic.index.html templates/minimal/html/_generic.item.html templates/minimal/html/help_controls.js templates/minimal/html/home.classlist.html templates/minimal/html/home.html templates/minimal/html/page.html templates/minimal/html/style.css templates/minimal/html/user.index.html templates/minimal/html/user.item.html templates/minimal/html/user.register.html templates/minimal/html/user.rego_progress.html templates/minimal/initial_data.py templates/minimal/schema.py test test/.cvsignore test/README.txt test/__init__.py test/benchmark.py test/db_test_base.py test/mocknull.py test/session_common.py test/test_actions.py test/test_anydbm.py test/test_cgi.py test/test_dates.py test/test_hyperdbvals.py test/test_indexer.py test/test_locking.py test/test_mailgw.py test/test_mailsplit.py test/test_metakit.py test/test_multipart.py test/test_mysql.py test/test_postgresql.py test/test_rfc2822.py test/test_schema.py test/test_security.py test/test_sqlite.py test/test_templating.py test/test_token.py test/test_tsearch2.py tools tools/.cvsignore tools/base64 tools/fixroles.py tools/load_tracker.py tools/migrate-queries.py tools/pygettext.py

erik.forsberg python-checkins at python.org
Sun Nov 5 21:30:53 CET 2006


Author: erik.forsberg
Date: Sun Nov  5 21:30:25 2006
New Revision: 52625

Added:
   tracker/vendor/roundup/current/.cvsignore
   tracker/vendor/roundup/current/BUILD.txt
   tracker/vendor/roundup/current/CHANGES.txt
   tracker/vendor/roundup/current/COPYING.txt
   tracker/vendor/roundup/current/ChangeLog
   tracker/vendor/roundup/current/MANIFEST.in
   tracker/vendor/roundup/current/README.txt
   tracker/vendor/roundup/current/cgi-bin/
   tracker/vendor/roundup/current/cgi-bin/roundup.cgi   (contents, props changed)
   tracker/vendor/roundup/current/demo.py
   tracker/vendor/roundup/current/detectors/
   tracker/vendor/roundup/current/detectors/creator_resolution.py
   tracker/vendor/roundup/current/detectors/emailauditor.py
   tracker/vendor/roundup/current/detectors/newissuecopy.py
   tracker/vendor/roundup/current/doc/
   tracker/vendor/roundup/current/doc/.cvsignore
   tracker/vendor/roundup/current/doc/FAQ.txt
   tracker/vendor/roundup/current/doc/Makefile
   tracker/vendor/roundup/current/doc/ZPL.txt
   tracker/vendor/roundup/current/doc/admin_guide.txt
   tracker/vendor/roundup/current/doc/announcement.txt
   tracker/vendor/roundup/current/doc/customizing.txt
   tracker/vendor/roundup/current/doc/debugging.txt
   tracker/vendor/roundup/current/doc/default.css
   tracker/vendor/roundup/current/doc/design.txt
   tracker/vendor/roundup/current/doc/developers.txt
   tracker/vendor/roundup/current/doc/features.txt
   tracker/vendor/roundup/current/doc/glossary.txt
   tracker/vendor/roundup/current/doc/images/
   tracker/vendor/roundup/current/doc/images/edit.png   (contents, props changed)
   tracker/vendor/roundup/current/doc/images/hyperdb.png   (contents, props changed)
   tracker/vendor/roundup/current/doc/images/logo-acl-medium.png   (contents, props changed)
   tracker/vendor/roundup/current/doc/images/logo-codesourcery-medium.png   (contents, props changed)
   tracker/vendor/roundup/current/doc/images/logo-software-carpentry-standard.png   (contents, props changed)
   tracker/vendor/roundup/current/doc/images/roundup-1.png   (contents, props changed)
   tracker/vendor/roundup/current/doc/images/roundup.png   (contents, props changed)
   tracker/vendor/roundup/current/doc/implementation.txt
   tracker/vendor/roundup/current/doc/index.txt
   tracker/vendor/roundup/current/doc/installation.txt
   tracker/vendor/roundup/current/doc/mysql.txt
   tracker/vendor/roundup/current/doc/original_overview.html
   tracker/vendor/roundup/current/doc/overview.txt
   tracker/vendor/roundup/current/doc/postgresql.txt
   tracker/vendor/roundup/current/doc/roundup-admin.1
   tracker/vendor/roundup/current/doc/roundup-demo.1
   tracker/vendor/roundup/current/doc/roundup-favicon.ico   (contents, props changed)
   tracker/vendor/roundup/current/doc/roundup-mailgw.1
   tracker/vendor/roundup/current/doc/roundup-server.1
   tracker/vendor/roundup/current/doc/roundup-server.ini.example
   tracker/vendor/roundup/current/doc/spec.html
   tracker/vendor/roundup/current/doc/tracker_templates.txt
   tracker/vendor/roundup/current/doc/upgrading.txt
   tracker/vendor/roundup/current/doc/user_guide.txt
   tracker/vendor/roundup/current/doc/whatsnew-0.7.txt
   tracker/vendor/roundup/current/doc/whatsnew-0.8.txt
   tracker/vendor/roundup/current/frontends/
   tracker/vendor/roundup/current/frontends/README.txt
   tracker/vendor/roundup/current/frontends/ZRoundup/
   tracker/vendor/roundup/current/frontends/ZRoundup/.cvsignore
   tracker/vendor/roundup/current/frontends/ZRoundup/ZRoundup.py
   tracker/vendor/roundup/current/frontends/ZRoundup/__init__.py
   tracker/vendor/roundup/current/frontends/ZRoundup/dtml/
   tracker/vendor/roundup/current/frontends/ZRoundup/dtml/manage_addZRoundupForm.dtml
   tracker/vendor/roundup/current/frontends/ZRoundup/icons/
   tracker/vendor/roundup/current/frontends/ZRoundup/icons/tick_symbol.gif   (contents, props changed)
   tracker/vendor/roundup/current/frontends/ZRoundup/refresh.txt
   tracker/vendor/roundup/current/locale/
   tracker/vendor/roundup/current/locale/.cvsignore
   tracker/vendor/roundup/current/locale/GNUmakefile
   tracker/vendor/roundup/current/locale/de.po
   tracker/vendor/roundup/current/locale/en.po
   tracker/vendor/roundup/current/locale/es_AR.po   (contents, props changed)
   tracker/vendor/roundup/current/locale/fr.po
   tracker/vendor/roundup/current/locale/lt.po   (contents, props changed)
   tracker/vendor/roundup/current/locale/roundup.pot
   tracker/vendor/roundup/current/locale/ru.po
   tracker/vendor/roundup/current/locale/zh_CN.po
   tracker/vendor/roundup/current/locale/zh_TW.po   (contents, props changed)
   tracker/vendor/roundup/current/patches/
   tracker/vendor/roundup/current/patches/20020205.alternate_auth
   tracker/vendor/roundup/current/roundup/
   tracker/vendor/roundup/current/roundup/.cvsignore
   tracker/vendor/roundup/current/roundup/__init__.py
   tracker/vendor/roundup/current/roundup/admin.py
   tracker/vendor/roundup/current/roundup/backends/
   tracker/vendor/roundup/current/roundup/backends/.cvsignore
   tracker/vendor/roundup/current/roundup/backends/__init__.py
   tracker/vendor/roundup/current/roundup/backends/back_anydbm.py
   tracker/vendor/roundup/current/roundup/backends/back_metakit.py   (contents, props changed)
   tracker/vendor/roundup/current/roundup/backends/back_mysql.py
   tracker/vendor/roundup/current/roundup/backends/back_postgresql.py
   tracker/vendor/roundup/current/roundup/backends/back_sqlite.py
   tracker/vendor/roundup/current/roundup/backends/back_tsearch2.py
   tracker/vendor/roundup/current/roundup/backends/blobfiles.py
   tracker/vendor/roundup/current/roundup/backends/indexer_common.py
   tracker/vendor/roundup/current/roundup/backends/indexer_dbm.py
   tracker/vendor/roundup/current/roundup/backends/indexer_rdbms.py
   tracker/vendor/roundup/current/roundup/backends/indexer_xapian.py
   tracker/vendor/roundup/current/roundup/backends/locking.py
   tracker/vendor/roundup/current/roundup/backends/portalocker.py
   tracker/vendor/roundup/current/roundup/backends/rdbms_common.py
   tracker/vendor/roundup/current/roundup/backends/sessions_dbm.py
   tracker/vendor/roundup/current/roundup/backends/sessions_rdbms.py
   tracker/vendor/roundup/current/roundup/backends/tsearch2_setup.py
   tracker/vendor/roundup/current/roundup/cgi/
   tracker/vendor/roundup/current/roundup/cgi/.cvsignore
   tracker/vendor/roundup/current/roundup/cgi/MultiMapping.py
   tracker/vendor/roundup/current/roundup/cgi/PageTemplates/
   tracker/vendor/roundup/current/roundup/cgi/PageTemplates/.cvsignore
   tracker/vendor/roundup/current/roundup/cgi/PageTemplates/Expressions.py
   tracker/vendor/roundup/current/roundup/cgi/PageTemplates/GlobalTranslationService.py
   tracker/vendor/roundup/current/roundup/cgi/PageTemplates/MultiMapping.py
   tracker/vendor/roundup/current/roundup/cgi/PageTemplates/PageTemplate.py   (contents, props changed)
   tracker/vendor/roundup/current/roundup/cgi/PageTemplates/PathIterator.py
   tracker/vendor/roundup/current/roundup/cgi/PageTemplates/PythonExpr.py
   tracker/vendor/roundup/current/roundup/cgi/PageTemplates/README.txt
   tracker/vendor/roundup/current/roundup/cgi/PageTemplates/TALES.py
   tracker/vendor/roundup/current/roundup/cgi/PageTemplates/__init__.py
   tracker/vendor/roundup/current/roundup/cgi/TAL/
   tracker/vendor/roundup/current/roundup/cgi/TAL/.cvsignore
   tracker/vendor/roundup/current/roundup/cgi/TAL/DummyEngine.py
   tracker/vendor/roundup/current/roundup/cgi/TAL/HTMLParser.py
   tracker/vendor/roundup/current/roundup/cgi/TAL/HTMLTALParser.py
   tracker/vendor/roundup/current/roundup/cgi/TAL/README.txt
   tracker/vendor/roundup/current/roundup/cgi/TAL/TALDefs.py
   tracker/vendor/roundup/current/roundup/cgi/TAL/TALGenerator.py
   tracker/vendor/roundup/current/roundup/cgi/TAL/TALInterpreter.py
   tracker/vendor/roundup/current/roundup/cgi/TAL/TALParser.py
   tracker/vendor/roundup/current/roundup/cgi/TAL/TranslationContext.py
   tracker/vendor/roundup/current/roundup/cgi/TAL/XMLParser.py
   tracker/vendor/roundup/current/roundup/cgi/TAL/__init__.py
   tracker/vendor/roundup/current/roundup/cgi/TAL/markupbase.py
   tracker/vendor/roundup/current/roundup/cgi/TAL/talgettext.py
   tracker/vendor/roundup/current/roundup/cgi/TranslationService.py
   tracker/vendor/roundup/current/roundup/cgi/ZTUtils/
   tracker/vendor/roundup/current/roundup/cgi/ZTUtils/.cvsignore
   tracker/vendor/roundup/current/roundup/cgi/ZTUtils/Batch.py
   tracker/vendor/roundup/current/roundup/cgi/ZTUtils/Iterator.py
   tracker/vendor/roundup/current/roundup/cgi/ZTUtils/__init__.py
   tracker/vendor/roundup/current/roundup/cgi/__init__.py
   tracker/vendor/roundup/current/roundup/cgi/accept_language.py   (contents, props changed)
   tracker/vendor/roundup/current/roundup/cgi/actions.py   (contents, props changed)
   tracker/vendor/roundup/current/roundup/cgi/apache.py
   tracker/vendor/roundup/current/roundup/cgi/cgitb.py
   tracker/vendor/roundup/current/roundup/cgi/client.py
   tracker/vendor/roundup/current/roundup/cgi/exceptions.py   (contents, props changed)
   tracker/vendor/roundup/current/roundup/cgi/form_parser.py   (contents, props changed)
   tracker/vendor/roundup/current/roundup/cgi/templating.py
   tracker/vendor/roundup/current/roundup/cgi/zLOG.py
   tracker/vendor/roundup/current/roundup/configuration.py
   tracker/vendor/roundup/current/roundup/date.py
   tracker/vendor/roundup/current/roundup/exceptions.py
   tracker/vendor/roundup/current/roundup/hyperdb.py
   tracker/vendor/roundup/current/roundup/i18n.py
   tracker/vendor/roundup/current/roundup/init.py
   tracker/vendor/roundup/current/roundup/install_util.py
   tracker/vendor/roundup/current/roundup/instance.py
   tracker/vendor/roundup/current/roundup/mailer.py
   tracker/vendor/roundup/current/roundup/mailgw.py
   tracker/vendor/roundup/current/roundup/msgfmt.py
   tracker/vendor/roundup/current/roundup/password.py
   tracker/vendor/roundup/current/roundup/rfc2822.py
   tracker/vendor/roundup/current/roundup/roundupdb.py
   tracker/vendor/roundup/current/roundup/scripts/
   tracker/vendor/roundup/current/roundup/scripts/.cvsignore
   tracker/vendor/roundup/current/roundup/scripts/__init__.py
   tracker/vendor/roundup/current/roundup/scripts/roundup_admin.py
   tracker/vendor/roundup/current/roundup/scripts/roundup_demo.py
   tracker/vendor/roundup/current/roundup/scripts/roundup_gettext.py
   tracker/vendor/roundup/current/roundup/scripts/roundup_mailgw.py
   tracker/vendor/roundup/current/roundup/scripts/roundup_server.py
   tracker/vendor/roundup/current/roundup/security.py
   tracker/vendor/roundup/current/roundup/support.py
   tracker/vendor/roundup/current/roundup/token.py
   tracker/vendor/roundup/current/roundup/version_check.py
   tracker/vendor/roundup/current/run_tests.py
   tracker/vendor/roundup/current/scripts/
   tracker/vendor/roundup/current/scripts/README.txt
   tracker/vendor/roundup/current/scripts/add-issue   (contents, props changed)
   tracker/vendor/roundup/current/scripts/copy-user.py   (contents, props changed)
   tracker/vendor/roundup/current/scripts/hyperdb_example.py
   tracker/vendor/roundup/current/scripts/imapServer.py
   tracker/vendor/roundup/current/scripts/import_sf.py
   tracker/vendor/roundup/current/scripts/roundup-reminder   (contents, props changed)
   tracker/vendor/roundup/current/scripts/roundup.rc-debian
   tracker/vendor/roundup/current/scripts/schema_diagram.py
   tracker/vendor/roundup/current/scripts/server-ctl   (contents, props changed)
   tracker/vendor/roundup/current/setup.py
   tracker/vendor/roundup/current/templates/
   tracker/vendor/roundup/current/templates/classic/
   tracker/vendor/roundup/current/templates/classic/.cvsignore
   tracker/vendor/roundup/current/templates/classic/TEMPLATE-INFO.txt
   tracker/vendor/roundup/current/templates/classic/detectors/
   tracker/vendor/roundup/current/templates/classic/detectors/.cvsignore
   tracker/vendor/roundup/current/templates/classic/detectors/messagesummary.py
   tracker/vendor/roundup/current/templates/classic/detectors/nosyreaction.py
   tracker/vendor/roundup/current/templates/classic/detectors/statusauditor.py
   tracker/vendor/roundup/current/templates/classic/detectors/userauditor.py
   tracker/vendor/roundup/current/templates/classic/extensions/
   tracker/vendor/roundup/current/templates/classic/extensions/README.txt
   tracker/vendor/roundup/current/templates/classic/html/
   tracker/vendor/roundup/current/templates/classic/html/_generic.calendar.html
   tracker/vendor/roundup/current/templates/classic/html/_generic.collision.html
   tracker/vendor/roundup/current/templates/classic/html/_generic.help.html
   tracker/vendor/roundup/current/templates/classic/html/_generic.index.html
   tracker/vendor/roundup/current/templates/classic/html/_generic.item.html
   tracker/vendor/roundup/current/templates/classic/html/file.index.html
   tracker/vendor/roundup/current/templates/classic/html/file.item.html
   tracker/vendor/roundup/current/templates/classic/html/help_controls.js
   tracker/vendor/roundup/current/templates/classic/html/home.classlist.html
   tracker/vendor/roundup/current/templates/classic/html/home.html
   tracker/vendor/roundup/current/templates/classic/html/issue.index.html
   tracker/vendor/roundup/current/templates/classic/html/issue.item.html
   tracker/vendor/roundup/current/templates/classic/html/issue.search.html
   tracker/vendor/roundup/current/templates/classic/html/keyword.item.html
   tracker/vendor/roundup/current/templates/classic/html/msg.index.html
   tracker/vendor/roundup/current/templates/classic/html/msg.item.html
   tracker/vendor/roundup/current/templates/classic/html/page.html
   tracker/vendor/roundup/current/templates/classic/html/query.edit.html
   tracker/vendor/roundup/current/templates/classic/html/query.item.html
   tracker/vendor/roundup/current/templates/classic/html/style.css
   tracker/vendor/roundup/current/templates/classic/html/user.forgotten.html
   tracker/vendor/roundup/current/templates/classic/html/user.index.html
   tracker/vendor/roundup/current/templates/classic/html/user.item.html
   tracker/vendor/roundup/current/templates/classic/html/user.register.html
   tracker/vendor/roundup/current/templates/classic/html/user.rego_progress.html
   tracker/vendor/roundup/current/templates/classic/initial_data.py
   tracker/vendor/roundup/current/templates/classic/schema.py
   tracker/vendor/roundup/current/templates/minimal/
   tracker/vendor/roundup/current/templates/minimal/.cvsignore
   tracker/vendor/roundup/current/templates/minimal/TEMPLATE-INFO.txt
   tracker/vendor/roundup/current/templates/minimal/detectors/
   tracker/vendor/roundup/current/templates/minimal/detectors/.cvsignore
   tracker/vendor/roundup/current/templates/minimal/detectors/userauditor.py
   tracker/vendor/roundup/current/templates/minimal/extensions/
   tracker/vendor/roundup/current/templates/minimal/extensions/README.txt
   tracker/vendor/roundup/current/templates/minimal/html/
   tracker/vendor/roundup/current/templates/minimal/html/_generic.calendar.html
   tracker/vendor/roundup/current/templates/minimal/html/_generic.collision.html
   tracker/vendor/roundup/current/templates/minimal/html/_generic.help.html
   tracker/vendor/roundup/current/templates/minimal/html/_generic.index.html
   tracker/vendor/roundup/current/templates/minimal/html/_generic.item.html
   tracker/vendor/roundup/current/templates/minimal/html/help_controls.js
   tracker/vendor/roundup/current/templates/minimal/html/home.classlist.html
   tracker/vendor/roundup/current/templates/minimal/html/home.html
   tracker/vendor/roundup/current/templates/minimal/html/page.html
   tracker/vendor/roundup/current/templates/minimal/html/style.css
   tracker/vendor/roundup/current/templates/minimal/html/user.index.html
   tracker/vendor/roundup/current/templates/minimal/html/user.item.html
   tracker/vendor/roundup/current/templates/minimal/html/user.register.html
   tracker/vendor/roundup/current/templates/minimal/html/user.rego_progress.html
   tracker/vendor/roundup/current/templates/minimal/initial_data.py
   tracker/vendor/roundup/current/templates/minimal/schema.py
   tracker/vendor/roundup/current/test/
   tracker/vendor/roundup/current/test/.cvsignore
   tracker/vendor/roundup/current/test/README.txt
   tracker/vendor/roundup/current/test/__init__.py
   tracker/vendor/roundup/current/test/benchmark.py
   tracker/vendor/roundup/current/test/db_test_base.py
   tracker/vendor/roundup/current/test/mocknull.py
   tracker/vendor/roundup/current/test/session_common.py
   tracker/vendor/roundup/current/test/test_actions.py   (contents, props changed)
   tracker/vendor/roundup/current/test/test_anydbm.py
   tracker/vendor/roundup/current/test/test_cgi.py
   tracker/vendor/roundup/current/test/test_dates.py
   tracker/vendor/roundup/current/test/test_hyperdbvals.py
   tracker/vendor/roundup/current/test/test_indexer.py
   tracker/vendor/roundup/current/test/test_locking.py
   tracker/vendor/roundup/current/test/test_mailgw.py
   tracker/vendor/roundup/current/test/test_mailsplit.py
   tracker/vendor/roundup/current/test/test_metakit.py
   tracker/vendor/roundup/current/test/test_multipart.py
   tracker/vendor/roundup/current/test/test_mysql.py
   tracker/vendor/roundup/current/test/test_postgresql.py
   tracker/vendor/roundup/current/test/test_rfc2822.py
   tracker/vendor/roundup/current/test/test_schema.py
   tracker/vendor/roundup/current/test/test_security.py
   tracker/vendor/roundup/current/test/test_sqlite.py
   tracker/vendor/roundup/current/test/test_templating.py
   tracker/vendor/roundup/current/test/test_token.py
   tracker/vendor/roundup/current/test/test_tsearch2.py
   tracker/vendor/roundup/current/tools/
   tracker/vendor/roundup/current/tools/.cvsignore
   tracker/vendor/roundup/current/tools/base64   (contents, props changed)
   tracker/vendor/roundup/current/tools/fixroles.py
   tracker/vendor/roundup/current/tools/load_tracker.py   (contents, props changed)
   tracker/vendor/roundup/current/tools/migrate-queries.py
   tracker/vendor/roundup/current/tools/pygettext.py
Log:

Importing roundup 1.1.2.


Added: tracker/vendor/roundup/current/.cvsignore
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/.cvsignore	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,9 @@
+*.pyc
+*.pyo
+localconfig.py
+build
+demo
+dist
+MANIFEST
+_test_*
+*.cover

Added: tracker/vendor/roundup/current/BUILD.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/BUILD.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,51 @@
+Building Releases
+=================
+
+Roundup is currently a source-only release - it has no binary components. I
+want it to stay that way, too. This document describes how to build a
+source release. Users of Roundup should read the doc/installation.txt file
+to find out how to install this software.
+
+Building and distributing a release of Roundup is done by running:
+
+1.  Make sure the unit tests run! "./run_tests.py"
+2.  Tag the CVS for the release, eg. "cvs tag -R release-0-6-3"
+3.  Edit roundup/__init__.py and doc/announcement.txt to reflect the new
+    version and appropriate announcements. Add truncated announcement to
+    setup.py description field.
+4.  Clean out all *.orig, *.rej, .#* files from the source.
+5.  python setup.py clean --all
+6.  Edit setup.py to ensure that all information therein (version, contact
+    information etc) is correct.
+7.  python setup.py sdist --manifest-only
+8.  Check the MANIFEST to make sure that any new files are included. If
+    they are not, edit MANIFEST.in to include them. "Documentation" for
+    MANIFEST.in may be found in disutils.filelist._parse_template_line.
+9.  python setup.py sdist
+    (if you find sdist a little verbose, add "--quiet" to the end of the
+     command)
+10. Unpack the new dist file in /tmp then a) run_test.py and b) demo.py
+    with all available Python versions.
+11. Generate gpg signature with "gpg -a --detach-sign"
+12. python setup.py bdist_rpm
+13. python setup.py bdist_wininst
+14. Send doc/announcement.txt to python-announce at python.org
+15. Notify any other news services as appropriate...
+
+      http://freshmeat.net/projects/roundup/
+
+
+So, those commands in a nice, cut'n'pasteable form::
+
+ find . -name '*.orig' -exec rm {} \;
+ find . -name '*.rej' -exec rm {} \;
+ find . -name '.#*' -exec rm {} \;
+ python setup.py clean --all
+ python setup.py sdist --manifest-only
+ python setup.py sdist --quiet
+ python setup.py bdist_rpm
+ python setup.py bdist_wininst
+ python setup.py register
+ python2.5 setup.py sdist upload --sign
+
+

Added: tracker/vendor/roundup/current/CHANGES.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/CHANGES.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,1873 @@
+This file contains the changes to the Roundup system over time. The entries
+are given with the most recent entry first.
+
+2006-04-27 1.1.2
+Feature:
+- server-ctl script uses server configuration file (sf bug 1443805)
+
+Fixed:
+- progress display in roundup-admin reindex
+- bug in menu() permission filter (sf bug 1444440)
+- indexing may be turned off for FileClass "content" now
+  ("content" and "type" properties are now automatically included in the
+  FileClass schema where previously the "content" property was faked and
+  "type" was optional)
+- verbose output during import is optional now (sf bug 1475624)
+- escape *all* uses of "schema" in mysql backend (sf bug 1472120)
+- responses to user rego email (sf bug 1470254)
+- dangling connections in session handling (sf bug 1463359)
+- reduced frequency of session timestamp update
+- classhelp popup pagination forgot about "type" (sf bug 1465836)
+- umask is now configurable (with the same 0002 default)
+- sorting of entries in classhelp popup (sf bug 1449000)
+- allow single digit seconds in date spec (sf bug 1447141)
+- prevent generation of new single-digit seconds dates (sf bug 1429390)
+- implement close() on all indexers (sf bug 1242477)
+
+
+2006-03-03 1.1.1
+Fixed:
+- failure with browsers not sending "Accept-Language" header
+  (sf bugs 1429646 and 1435335)
+- translate class name in "required property not supplied" error message
+  (sf bug 1429669)
+- error in link property lookups with numeric-alike key values (sf bug 1424550)
+- ignore UTF-8 BOM in .po files
+- add permission filter to menu() implementations (sf bug 1431188)
+- lithuanian translation updated by Nerijus Baliunas (sf patch 1411175)
+- incompatibility with python2.3 in the mailer module (sf bug 1432602)
+- typo in SMTP TLS option name: "MAIL_TLS_CERFILE" (sf bug 1435452)
+- email obfuscation code in html templating is more robust
+- blank-title subject line handling (sf bug 1442121)
+- "All users may only view and edit issues, files and messages they
+  create" example in docs (sf bug 1439086)
+- saving of queries (sf bug 1436169)
+- "Adding a new constrained field to the classic schema" example in docs
+  (sf bug 1433118)
+- security check in mailgw (sf bug 1442145)
+- "clear this message" (sf bug 1429367)
+- escape all uses of "schema" in mysql backend (sf bug 1397569)
+- date spec wasn't allowing week intervals
+
+
+2006-02-10 1.1.0
+Feature:
+- trackers may configure custom stop-words for the full-text indexer
+- login may now be for a single session (and this is the default)
+- trackers may hide exceptions from web users (they will be mailed to the
+  tracker admin) (hiding is the default)
+- include "clear this message" link in the "ok" message bar
+
+Fixed:
+- fixes in scripts/import_sf.py
+- fix some unicode bugs in roundup-admin import
+- Xapian indexer wasn't actually being used and its reindexing of existing
+  data was busted to boot
+- roundup-admin import wasn't indexing message content
+- allow dispname to be passed to renderWith (sf bug 1424587)
+- rename dispname to @dispname to avoid name clashes in the future
+- fixed schema migration problem when Class keys were removed
+
+
+2006-02-03 1.0.1
+Feature:
+- scripts/import_sf.py will import a tracker from Sourceforge.NET
+- added hasRole() to HTMLUser
+
+Fixed:
+- SQL generation for sort/group by separate Link properties (sf bug
+  1417565)
+- fix timezone offsetting in email Date: header
+- fix security check for hasPermission('Permission', None)
+
+
+2006-01-27 1.0
+Feature:
+- Lithuanian translation by Aiste Kesminaite
+- Web User Interface language selection by form variable @language,
+  browser cookie or HTTP header Accept-Language (sf patch 1360321)
+- initial values for configuration options may be passed on
+  'roundup-admin install' command line (based on sf patch 1237110)
+- favicon.ico image may be changed with server config option (sf patch 1355661)
+- Password objects initialized from plaintext remember plaintext value
+  (sf rfe 1379447)
+- Roundup installation document includes configuration example
+  for Exim Internet Mailer (sf bug 1393860)
+- enable registration confirmation by web only (sf bug 1381675)
+- allow preselection of values in templating menu()s (sf patch 1396085)
+- display the query name in the header (sf feature 1298535 / patch 1349387)
+- classhelp works with Link properties now (sf bug 1410290)
+- added setorderprop() and setlabelprop() to Class (sf features 1379534,
+  1379490)
+- CSV encoding support (sf bug 1240848)
+- fields rendered with StructuredText are hyperlinked by default
+- additional attributes for input element may be passed to the 'field'
+  method of a property wrapper
+- added "copy_url" method to generate a URL for copying an item
+
+Fixed:
+- MySQL now creates String columns using the TEXT column type
+- password.crypt won't work with md5 passwords (sf bug 1372253)
+- use quoted printable encoding for nosy attachments that have MIME
+  type 'text/plain' but contain 8-bit characters (sf bug 1381559)
+- login name and email address fields in the classic template
+  are highlighted as required fields (sf bug 1392364)
+- french translation updated by Patrick Decat (sf patch 1397059)
+- HTTP authorization takes precedence over session cookie (sf bug 1396134)
+- enforce correct encoding of PostgreSQL backend (sf bug 1374235)
+- grouping/sorting on link to same class fixed (sf bug 1404930)
+- all backends implement the retired check in getnodeids (sf bug 1290560)
+- fix detection of "missing" existing values in CGI form parser (sf bug
+  1414149)
+- ZRoundup works again (sf bug 1263842)
+- default user template does not display password fields and submit button
+  when editing is not allowed
+- fix StructuredText import in cgi.templating
+- have "System Messages" be marked as such again (sf bug 1281907)
+- enable editing of public queries (sf bug 966144)
+
+
+2005-10-07 0.9.0b1
+Feature:
+- added "imapServer.py" script (sf patch 934567)
+- added date selection popup windows (thanks Marcus Priesch)
+- added Xapian indexer; replaces standard indexers if Xapian is available
+- mailgw subject parsing has configurable levels of strictness
+- nosy messages may be sent individually to all recipients
+- remember where we came from when logging in (sf patch 1312889)
+
+
+2006-??-?? 0.8.6
+Fixed:
+- french translation updated by Patrick Decat (sf patch 1397059)
+- tighten up Date parsing to not allow 'M/D/YY' (or 'D/M/YY) (sf bug
+  1290550)
+- handle "schema" being reserved word in MySQL 5+ (sf bug 1397569)
+- fixed documentation of filter() in the case of multiple values in a
+  String search (sf bug 1373396)
+- fix comma-separated ID filter spec in web requests (sf bug 1396278)
+- fix Date: header generation to be LOCALE-agnostic (sf bug 1352624)
+- fix admin doc description of roundup-server config file
+- fix redirect after instant registration (sf bug 1381676)
+- fix permission checks in cgi interface (sf bug 1289557)
+- fix permission check on RetireAction (sf bug 1407342)
+- timezone now applied to date for pretty-format (sf bug 1406861)
+- fix mangling of "_" in mail Subject class name (sf bug 1413852)
+- catch bad classname in URL (related to sf bug 1240541)
+- clean up digested_file_types (sf bug 1268303)
+- fix permission checks in mailgw (sf bug 1263655)
+- fix encoding of subject in generated error message (sf bug 1414465)
+
+
+2005-10-07 0.8.5
+Feature:
+- Argentinian Spanish translation by Ramiro Morales
+
+Fixed:
+- Display of Multilinks where linked Class labelprop values are None
+- Fix references to the old * Registration Permissions
+- Fix missing merge of fix to sf bug 1177057
+- Fix RDBMS indexer indexing UTF-8 words that encode to > 30 chars
+- Handle invalidly-specified charsets in incoming email
+
+
+2005-07-18 0.8.4
+Fixed:
+- extra CRs in CSV export files on Windows platform (sf bug 1195742)
+- activity RDBMS columns were being reported in changes
+- fix name collision in roundup.cgi script (sf bug 1203795)
+- fix handling of invalid interval input
+- search locale files relative ro roundup installation path (sf bug 1219689)
+- use translation for boolean property rendering (sf bug 1225152)
+- enabled disabling of REMOTE_USER for when it's not a valid username (sf
+  bug 1190187)
+- fix invocation of hasPermission from templating code (sf bug 1224172)
+- have 'roundup-admin security' display property restrictions (sf bug
+  1222135)
+- fixed templating menu() sort_on handling (sf bug 1221936)
+- allow specification of pagesize, sorting and filtering in "classhelp"
+  popups (sf bug 1211800)
+- handle dropped properies in rdbms/metakit journal export (sf bug 1203569)
+- handle missing Subject lines better (sf bug 1198729)
+- sort/group by missing values correctly (sf bugs 1198623, 1176897)
+- discard, don't bounce messages to the mailgw when the messages's sender
+  is invalid (ie. when we try to bounce, we get a 550 "unknown user
+  account" response from the SMTP server) (sf bug 1190906)
+- removed debugging code from cgi/actions.py
+- refactored hyperdb.rawToHyperdb, allowing a number of improvements
+  (thanks Ralf Schlatterbeck)
+- don't try to set a timeout for IMAPS (thanks Paul Jimenez)
+- present Reject exception messages to web users (sf bug 1237685)
+
+
+2005-05-02 0.8.3
+Feature:
+- chinese translation by limodou
+
+Fixed:
+- fix reference to The Zope Book in Roundup FAQ
+- disabled file logging in Roundup test suite (sf bug 1155649)
+- return original string if message issue xref isn't valid
+- fix nosyreaction.py to stop it setting the nosy list unnecessarily
+  (see doc/upgrading.txt for how to fix in your trackers)
+- after logout, always display tracker home page
+- web forms don't create new items if no item properties are set from UI
+- item creation failed if multilink fields had invalid entries (sf bug
+  1177602)
+- fix bdist_rpm (sf bug 1164328)
+- fix checking of "Email Access" for Anonymous email registration (sf bug
+  1177057)
+- disable "Email Access" for Anonymous by default to stop spam regsitering
+  users on public trackers
+- send errors in the web interface to a logfile by default. Use the
+  "debug" multiprocess mode (roundup-server) or the DEBUG_TO_CLIENT var
+  (roundup.cgi) to have the errors appear in your browser
+- fix setgid typo (sf bug 1171346)
+- fix faulty find_template filename facility (sf bug 1163629)
+- fix roundup-admin "export" so it creates the target dir if needed
+- "fix" roundup-admin "import" to not use "universal newline support" since
+  the csv module appears to have its own ideas about such things (sf bug
+  1163890)
+- fix installation docs referring to old-style configuration variables
+- fix roundup-admin "find" for searching Multilinks (sf bug 1189465)
+
+
+2005-03-03 0.8.2
+Feature:
+- roundup-server automatically redirects from trackers list
+  to the tracker page if there is only one tracker
+
+Fixed:
+- added content to ZRoundup refresh.txt file (sf bug 1147622)
+- fix invalid reference to csv.colon_separated
+- correct URL to What's New in setup.py meta-data
+- change AUTOCOMMIT=OFF to AUTOCOMMIT=0 for MySQL (sf bug 1143707)
+- compile message objects in 'setup.py build'
+- use backend datatype for journal timestamps in RDBMSes
+- fixes to the "Using an external password validation source"
+  customisation example (sf bugs 1153640 and 1155108)
+
+
+2005-02-17 0.8.1
+Fixed:
+- replaced MutlilinkIterator with multilinkGenerator (thanks Bob Ippolito)
+- fixed broken csv import in roundup.admin module
+- fixed braino in HTMLClass.filter() (sf bug 1124213)
+- change ZTUtils Iterator to always iter() its sequence argument
+
+
+2005-01-16 0.8.0
+Fixed:
+- fix roundup-server log and PID file paths to be absolute
+- fix initialisation of roundup-server in daemon mode so initialisation
+  errors are visible
+- fix: 'Logout' link was enabled on issue index page only
+- have Permissions only test the check function if itemid is suppled
+- modify cgi templating system to check item-level permissions in listings
+- enable batching in message and file listings
+- more documentation of security mechanisms (incl. sf patches 1117932,
+  1117860)
+- better unit tests for security mechanisms
+- code cleanup (sf patch 1115329 and additional)
+- issue search page allows setting of no sorting / grouping (sf bug
+  1119475)
+- better edit conflict handling (sf bug 1118790)
+- consistent text searching behaviour (AND everywhere) (sf bug 1101036)
+- fix handling of invalid date input (sf bug 1102165)
+- retain Boolean selections in edit error handling (sf bug 1101492)
+- fix initialisation of logging module from config file (sf bug 1108577)
+- removed rlog module (py 2.3 is minimum version now)
+- fixed class "help" listing paging (sf bug 1106329)
+- nicer error looking up values of None (response to sf bug 1108697)
+- fallback for (list) popups if javascript disabled (sf patch 1101626)
+
+
+2005-01-13 0.8.0b2
+Fixed:
+- note about how to run roundup demo in Windows (sf bug 1082090)
+- fix API for templating utils extensions - remove "utils" arg (sf bug 1081981)
+- back_sqlite.py is missing "import time" (sf bug 1081959)
+- fix (list) popup (sf bug 1083570)
+- fix some security assertions (sf bug 1085481)
+- 'roundup-server -S' always writes [trackers] section heading (sf bug 1088878)
+- fix port number as int in mysql connection info (sf bug 1082530)
+- fix setup.py to work with <Python2.3 (sf bug 1082801)
+- fix permissions checks in cgi templating (sf bug 1082755)
+- fix "Users may only edit their issues" example in docs
+- handle ~/.my.cnf files for MySQL defaults (sf bug 1096031)
+
+
+2004-12-08 0.8.0b1
+Feature:
+- added MD5 scheme for password hiding
+- added support for HTTP charset selection
+- implement __nonzero__ for HTMLProperty
+- remove "manual" locking of sqlite database
+- create a new RDBMS cursor after committing
+- added basic logging, and configuration of it and python's logging module
+- roundup-mailgw now logs fatal exceptions rather than mailing them to admin
+- add a default argument to the DateHTMLProperty.field method, and an
+  optional Interval (string or object) to the DateHTMLProperty.now (patch
+  from Vickenty Fesunov)
+- hide "(list)" popup links when issue is only viewable
+- roundup-server options -g and -u accept both ids and names (sf bug 983769)
+- roundup-server now has a configuration file (-C option)
+- added mod_python interface (see installation.txt)
+- reorganised tracker configuration, using ConfigParser config, cleaned-up
+  schema definition and implementing easier extension writing (sf rfe 661301)
+- Permissions may now be defined on a per-property basis
+- added "Create" Permission. Replaces the "Web"- and "Email Registration"
+  Permissions.
+- added option to turn off registration confirmation via email
+  ("instant_registration" in config) (sf rfe 922209)
+- roundup-admin reindex command may now work on single items or classes
+- multiple selection Link/Multilink search field (thanks Marlon van den Berg)
+- relaxed hyperlinking in web interface (accept "issue123" or "Issue 123")
+- record journaltag lookup ("fixes" sf bug 998140)
+- allow listing popup to be used in query forms (thanks Marcus Priesch)
+- roundup windows service may be installed with command line options
+  recognized by roundup-server (but not tracker specification arguments).
+  Use this to specify server configuration file for the service.
+- added experimental multi-thread server
+- don't try to import all backends in backends.__init__ unless we *want* to
+- unless in debug mode, keep a single persistent connection through a
+  single web or mailgw request.
+- HTTP Basic Authentication (sf patch 1067690)
+- extended security.addPermissionToRole to allow skipping the separate
+  getPermission call
+
+Fixed:
+- postgres backend open doesn't hide corruption in schema (sf bug 956375)
+- *dbm-style backends nuke() method now clear id counters
+- removed safeget() from the API (sf bug 994750)
+- demo tracker is always set up on localhost (sf bug 1049101)
+- relaxed URL designator syntax to allow issue[0]*1 (sf bug 1054523)
+
+
+2005-05-02 0.7.12
+Fixed:
+- handle capitalisation of class names in text hyperlinking (sf bug
+  1101043)
+- quote full-text search text in URL generation
+- fixed problem migrating mysql databases
+- fix search_checkboxes macro (sf patch 1113828)
+- fix bug in date editing in Metakit
+- allow suppression of search_text in indexargs_form (sf bug 1101548)
+- hack to fix some anydbm export problems (sf bug 1081454)
+- ignore AutoReply messages (sf patch 1085051)
+- fix ZRoundup syntax error (sf bug 1122335)
+- fix RDBMS clear() so it resets all class itemid counters
+
+
+2005-01-06 0.7.11
+Fixed:
+- index args URL generation broken in .10 (sf bug 1096027)
+- handle NotModified for non-static files (sf patch 1095790)
+- fix permission lookup in query editing
+
+
+2005-01-04 0.7.10
+Fixed:
+- reset ID counters if the database is cleared (thanks William)
+- apply IE caching "fix" to automatically serve up all pages expired
+- fix typo (sf patch 1076629)
+- fix hyperlinking of items (sf bug 1080251)
+- fix roundup-admin find command handling of Multilinks
+- fix some security assertions (sf bug 1085481)
+- don't set the title to nothing from incoming mail (thanks Bruce Guenter)
+- fix py2.4 strftime() API change bug (sf bug 1087746)
+- fix indexer searching with no valid words (sf bug 1086787)
+- updated searching / indexing docs
+- fix "(list)" popup when list is one item long (sf bug 1064716)
+- have RDBMS full-text indexer do AND searching (sf bug 1055435)
+- handle spaces in String index params in batching (sf bug 1054224)
+
+
+2004-10-26 0.7.9
+Feature:
+- DateHTMLProperty.field() accepts format string (thanks Wil Cooley)
+
+Fixed:
+- popup listing uses filter args (thanks Marlon van den Berg)
+- fixed editing of message contents
+- loosened the detection of issue cross-references in messages
+- open CSV files in "universal newline" mode
+- s/Modifed/Modified (thanks donfu)
+- applied patch fixing some form handling issues in ZRoundup (sf bug 995565)
+- enforce View Permission when serving file content (sf bug 1050470)
+- don't index common words (sf bug 1046612)
+- don't wrap query.item.html in a <span> (thanks Roch'e Compaan)
+- TAL expressions like 'request/show/whatever' return True
+  if the request does not contain explicit @columns list
+- NumberHTMLProperty should return '' not "None" if not set (thanks
+  William)
+- ensure multilink ordering in RDBMS backends (thanks Marcus Priesch, sf
+  bug 950963)
+- always honor indexme property on Strings (sf patch 1063711)
+- make hyperdb value parsing errors readable in mailgw errors
+- make anydbm journal export handle removed properties
+- allow use of XML templates again
+
+
+2004-10-15 0.7.8
+Fixed:
+- Clean out sessions / otks tables when migrating
+
+
+2004-10-11 0.7.7
+Fixed:
+- ZRoundup's search interface works now (sf bug 994957)
+- fixed history display when "ascending"
+- removed references to py2.3+ boolean values (sf bug 995682)
+- fix static file path normalisation in security check (thanks David Linke)
+- less specific messages for login failures (thanks Chris Withers)
+- Reject raised against email messages should result in email rejection, not
+  discarding of the message
+- mailgw can override the MAIL_DEFAULT_CLASS
+- handle Py2.3+ datetime objects as Date specs (sf bug 971300)
+- use row locking in MySQL newid() (sf bug 1034211)
+- add sanity check for sort and group on same property (sf bug 1033477)
+- extend OTK and session table value cols to TEXT (sf bug 1031271)
+- fix lookup of REMOTE_USER (sf bug 1002923)
+- new Interval props weren't created properly in rdbms
+- date.Interval() now accepts an Interval as a spec (sf bug 1041266)
+- handle deleted properties in RDBMS history
+- apply timezone in correct direction in user input (sf bug 1013097)
+- more efficient find() in RDBMS (sf bug 1012781)
+
+
+2004-07-21 0.7.6
+Fixed:
+- rdbms backend full text search failure after import (sf bug 980314)
+- rdbms backends not filtering correctly on link=None
+- fix anydbm journal import (sf bug 983166)
+- handle postgresql bug in SQL generation (sf bug 984591)
+- fix dates-from-Dates (sf bug 984604)
+- fix messageid generated when msgid is None for send_message (sf bug 987933)
+- make user permissions check more sane (fix search page for anonymous)
+- fixed RDBMS filter() for no matches from full-text search (sf bug 990778)
+- fixed DateHTMLProperty for invalid date entry (sf bug 986538)
+- fixed external password source example (sf bug 986601)
+- document the STATIC_FILES config var
+- implement the HTTP HEAD command (sf bug 992544)
+- fix journal export of files to remove content from CSV files
+- API clarification. Previously, the anydbm/bsddb/metakit filter() methods
+  had required exact matches to Multilink argument lists. The RDBMS
+  backends treated Multilink matches like all other data types - matching
+  any of the Multilink argument list is good enough. The latter behaviour
+  is implemented across the board now.
+- fix metakit handling of filter on Link==None
+
+
+2004-06-24 0.7.5
+Fixed:
+- force lookup of journal props in anydbm filtering
+- fixed lookup of "missing" Link values for new props in anydbm backend
+- allow list of values for id, Number and Boolean filtering in anydbm
+  backend
+- fixed some more mysql 0.6->0.7 upgrade bugs (sf bug 950410)
+- fixed Boolean values in postgresql (sf bugs 972546 and 972600)
+- fixed -g arg to roundup-server (sf bug 973946)
+- better roundup-server usage string (sf bug 973352)
+- include "context" always, as documented (sf bug 965447)
+- fixed REMOTE_USER (external HTTP Basic auth) (sf bug 977309)
+- fixed roundup-admin "find" to use better value parsing
+- fixed RDBMS Class.find() to handle None value in multiple find
+- export now stores file "content" in separate files in export directory
+
+
+2004-06-10 0.7.4
+Fixed:
+- re-acquire the OTK manager when we re-open the database
+- mailgw handler can close the database on us
+- fixed grouping by a NULL Link value
+- fixed anydbm import/export (sf bugs 965216, 964457, 964450)
+- fix python 2.3.3 strftime deprecation warning (sf patch 968398)
+- fix some column datatypes in postgresql and mysql (sf bugs 962611,
+  959177 and 964231)
+- fixed RDBMS journal packing (sf bug 959177)
+- fixed filtering by floats in anydbm (sf bug 963584)
+
+
+2004-05-28 0.7.3
+Fixed:
+- add "checked" to truth values for Boolean input
+- fixed import in metakit backend
+- fix SearchAction use of Class.filter(), and clarify API docs for same
+- ensure static files may only be served out of the tracker's "static
+  files" directory
+
+
+2004-05-17 0.7.2
+Fixed:
+- anydbm sorting with None values (sf bug 952853)
+- roundup-server -g option not recognised (sf bug 952310)
+- HTML templating isset() inverted (sf bug 951779)
+- otks manager missing (sf bug 952931)
+- mention DEFAULT_TIMEZONE requirement in upgrading doc (sf bug 952932)
+- fix DateHTMLProperty so local() can override user timezone (sf bug
+  953678)
+- fix anydbm sort/group direction handling, and make RDBMS sort/group use
+  Link'ed "order" properties (sf bug 953148)
+- fix Interval editing (sf bug 954891)
+
+
+2004-05-10 0.7.1
+Fixed:
+- several temp files made it into the source distribution (sf bug 949243)
+- typo in roundup/instance.py
+- missing CRLF var in rfc822.py (sf patch 949471)
+- fix user creation page
+- have roundup server pass though the cause of a "403 Forbidden" response
+- fix schema mutation in sqlite backends (thanks Tamer Fahmy)
+- make popup Javascript IE 5.0 friendly (thanks Marlon van den Berg)
+- fix RDBMS import (thanks Tamer Fahmy)
+
+
+2004-05-06 0.7.0
+Fixed:
+- sqlite migration drops some journal information (thanks David Linke)
+- user editing Role entry help text always appears
+- disable forking server when os.fork() not available (sf bug 938586)
+- removed Boolean from source to make py <2.3 happy (sf bug 938790)
+- fix nested scope bug in rdbms multilink sorting
+- re-seed the random number generator for each request
+- postgresql backend altered to not use popen (thanks Georges Martin)
+- fixed journal marshalling in RDBMS backends (sf bug 943627)
+- fixed handling of key values starting with numbers (sf bug 941363)
+- fixed journal "param" column size in RDBMS backends
+- fixed static file serving
+- fixed rego from email address (sf bug 947414)
+- fixed sqlite journal ordering issue
+- fixed mysql date range filtering
+
+
+2004-04-18 0.7.0b3
+Feature:
+- added a favicon
+- added url_quote and html_quote methods to the utils object
+- added isset method to HTMLProperty
+- database export now exports full journals too
+- tracker name at end of page title (sf rfe 926840)
+- roundup-server now uses the ForkingMixin
+- added another sample detector "creator_resolution"
+- added search_checkboxes as an option for the search form
+- added IMAP support to mail gateway (sf rfe 934000)
+- check MANIFEST against the files actually unpacked
+- roundupdb nosymessage() takes an optional bcc list
+
+Fixed:
+- mysql and postgresql schema mutation now handle added Multilinks
+- web CSV export was busted (as was any action returning a result)
+- MultiMapping deviated from the Zope C implementation in a number of
+  places (thanks Toby Sargeant)
+- MySQL and Postgresql use BOOL/BOOLEAN for Boolean types
+- OTK generation was busted (thanks Stuart D. Gathman)
+- export and import now include journals (incompatible with export < 0.7)
+- added "download_url" method to generate a correctly quoted URL for file
+  download links (sf bug 927745)
+- all uses of TRACKER_WEB now ensure it ends with a '/'
+- roundup-admin install checks for existing tracker in target home
+- grouping (and sorting) by multilink in RDBMS backends (sf bug 655702)
+- roundup scripts may now be asked for their version (sf rfe 798657)
+- sqlite backend had stopped using the global lock
+- better check for anonymous viewing of user items (sf bug 933510)
+- stop Interval from displaying an empty string (sf bug 934022)
+- fixed storage of some datatypes in some RDBMS backends
+
+
+2004-03-27 0.7.0b2
+Feature:
+- added CSV export to index pages
+- added emailauditor.py which works around a bug in IE. See
+  "detectors/emailauditor.py" for more info.
+- added dispatcher functionality - see upgrading.txt for more info
+- added Reject exception which may be raised by auditors. This is trapped
+  by mailgw and may be used to veto creation of file attachments or
+  messages. (sf bug 700265)
+- queries on a per-user basis, and public queries (sf "bug" 891798 :)
+- added DEFAULT_TIMEZONE (sf rfe 895139)
+- added HTML page template to the templating context as "template"
+- added is_retired to HTMLItems in templating
+
+Fixed:
+- Boolean, Date and Link HTML templating was broken
+- fix reporting of test inclusion in postgresql test
+- EditAction was confused about who "self" was
+- edit collision detection was broken for index-page edits
+- sqlite backend wasn't migrating multilink tables correctly
+- use SimpleCookie instead of Cookie (is an alias for the evil SmartCookie)
+- handle older sessions in session dbm
+- make presetunread more resilient to status Class changes
+- HTMLDatabase classes() was broken
+
+
+2004-03-24 0.7.0b1
+Major new features:
+- added postgresql backend (originally from sf patch 761740, many changes
+  since)
+- added new "actor" automatic property (indicates user who cause the last
+  "activity")
+- RDBMS backends implement their session and one-time-key stores and
+  full-text indexers; thus they are now performing their own locking
+  internally
+- all RDBMS backends now have indexes on several columns
+- support confirming registration by replying to the email (sf bug 763668)
+- all HTML templating methods now automatically check for permissions
+  (either view or edit as appropriate), greatly simplifying templates
+
+Other new features:
+- simple support for collision detection (sf rfe 648763)
+- support setgid and running on port < 1024 (sf patch 777528)
+- using Zope3's test runner now, allowing GC checks, nicer controls and
+  coverage analysis
+- change nosymessage and send_message to accept msgid=None (RFE #707235)
+- handle Resent-From: headers (sf bug 841151)
+- always sort MultilinkHTMLProperty in the correct order, usually
+  alphabetically (sf feature 790512)
+- added script for copying user(s) ("scripts/copy-user.py") from tracker
+  to tracker (sf patch 828963)
+- ignore incoming email with "Precedence: bulk" (sf patch 843489)
+- use HTTP 'Content-Length' header (modified sf patch 844577)
+- HTML generated is now HTML4 (or optionally XHTML) compliant (sf feature
+  814314 and sf patch 834620)
+- default stylesheet turns off sidebar when printing
+- allow direct supply of filter() arguments in templating (thanks Godefroid
+  Chapelle)
+- improved body_title slot in HTML templating (sf patch 873502)
+- HTMLLinkProperty field() method renders as a field now (thanks darryl)
+- cgi Action handlers may now return the actual content to be sent back to
+  the user (rather than using some template)
+- date.Date now handles fractional seconds
+
+Fixed:
+- mysql documentation fixed to note requirement of 4.0+ and InnoDB
+- added testing of schema mutation, fixed rdbms backends handling of a
+  couple of cases
+- HTML 4.01 validation on the 'classic' backend
+- messages to the mailgw can be about classes other than issues now.
+- signature matching is more precise (sf bug 827775).
+- anonymous user can no longer edit or view itself (sf bug 828901).
+- corrected typo in installation.html (sf bug 822967).
+- clarified listTemplates docstring.
+- print a nicer error message when the address is already in use 
+  (sf bug 798659).
+- remove empty lines before sending strings off to the csv parser 
+  (sf bug 821364).
+- centralised conversion of user-input data to hyperdb values (sf bug
+  802405, sf bug 817217, sf rfe 816994)
+- recalculate SHA on template files when installed tracker used as
+  template (sf bug 827510)
+- fixed ZRoundup (sf bug 624380)
+- the mail gateway now searches recursively for the text/plain and the
+  attachments of a message (sf bug 841241).
+- fixed display of feedback messages in some situations (sf bug 739545)
+- fixed ability to edit "content" property (sf bug 914062)
+
+Cleanup:
+- replace curuserid attribute on Database with the extended getuid() method
+- extract a new 'mailer' module for sending mail
+- extract a '_send_mail' method for testing mail sending
+- simplify backend importing
+- use roundup_server in demo.py
+- implement newItemAction using editItemAction
+- use FormError in client.py, moving the handling up to inner_main()
+- implemented semantic comparison of Message objects in test_mailgw
+- tidied up forms in default stylesheet
+- force textareas to use monospace fonts, lessening surprise on the user
+- moved out parts of client.py to new modules:
+  * actions.py - the xxxAction and xxxPermission functions refactored into 
+    Action classes
+  * exceptions.py - all exceptions
+  * form_parser.py - parsePropsFromForm & extractFormList in a FormParser
+    class
+
+
+2004-05-17 0.6.10
+Fixed:
+- mysql backend wasn't locking tracker
+- ensure static files may only be served out of the tracker's "static
+  files" directory
+
+
+2004-04-18 0.6.9
+Fixed:
+- paging in classhelp popup was broken
+- socket timeout error logging can fail
+- hyperlink designators in message display (sf bug 931828)
+- don't match retired items in RDBMS stringFind
+
+
+2004-04-01 0.6.8
+Fixed:
+- existing trackers (ie. live ones) may be used as templates for new
+  trackers - the TEMPLATE-INFO.txt name entry has the tracker's dir name
+  appended (so the demo tracker's template name is "classic-demo")
+- handle bad multilink input at item creation time better (sf bug 917834)
+- make sure email signature starts on a newline (sf bug 919759)
+- add line to rego email to help URL detection (sf bug 906247)
+- look harder for text/plain in email
+- fixed fallback excel writer in rcsv so it has a delimiter
+- fixed setup.py's use of listTemplates (!)
+- make rdbms serialise() less trusting
+- handle Boolean values in history HTML display
+
+
+2004-03-01 0.6.7
+Fixed:
+- be more backward-compatible when asking for EMAIL_CHARSET
+- made error on create consistent with edit when user enters invalid data
+  for Multilink and Link form fields (sf bug 904072)
+- made errors from bad input in the quick "Show issue:" form more
+  user-friendly (sf bug 904064)
+- don't add a query to a user's list if it's already there
+- nicer invalid property error in HTML templating
+- use EMAIL_CHARSET for message body too (still sf bug 900046)
+
+
+2004-02-25 0.6.6
+Fixed:
+- don't insert spaces into designators, it just confuses users (sf bug
+  898087)
+- Eudora can't handle utf-8 headers. We love Eudora. (sf bug 900046)
+- fixed bug in args to new DateHTMLProperty in the local() method (sf bug
+  901444)
+- fixed registration (sf bug 903283)
+- also changed rego to not use a 302 during confirmation, as this seems to
+  confuse some email clients or browsers.
+
+
+2004-02-16 0.6.5
+Fixed:
+- mailgw handling of subject-line errors
+- allow serving of FileClass file content when the class isn't called
+  "file" (eg. messages and other FileClasses)
+- allowed negative ids (ie. new item markers) in HTMLClass.getItem,
+  allowing "db/file_with_status/-1/status/menu" to generate a useful
+  widget
+- fixed content-type when templates are serving up xml (thanks Godefroid
+  Chapelle)
+- fixed IE double-submit when it shouldn't (sf bug 842254)
+- fixed check for JS pop()/push() to make more general (sf bug 877504)
+- fix re-enabling queries (sf bug 861940)
+- use supplied content-type on file uploads before trying filename)
+- fixed roundup-reminder script to use default schema (thanks Klamer Schutte)
+- fixed edit action / parsePropsFromForm to handle index-page edits better
+- safer logging from HTTP server (sf bug 896917)
+
+
+2003-12-17 0.6.4
+Fixed:
+- fixed date arithmetic to not allow day-of-month == 0 (sf bug 853306)
+- fixed date arithmetic to limit hours-per-day to 24, not 60
+- hard-coded python2.3-ism (socket.timeout) fixed
+- fixed activity displaying as future because of Date arithmetic fix in 0.6.3
+  (sf bug 842027).
+- fix Windows service mode for roundup-server (sf bug 819890)
+- fixed #white in cgitb (thanks Henrik Levkowetz)
+
+
+2003-11-14 0.6.3
+Fixed:
+- fixed detectors fix incorrectly fixed in bugfix release 0.6.2
+- added note to upgrading doc for detectors fix in 0.6.2
+- added script to help migrating queries from pre-0.6 trackers
+- fixed "documentation" of getnodeids in roundup.hyperdb
+- added flush() to DevNull (sf bug 835365)
+- fixed javascript for help window for only one checkbox case
+- date arithmetic was utterly broken, and has been for a long time.
+  Date +/- Interval now works, and Date - Date also works (produces
+  an Interval.
+- handle socket timeout exception (thanks Marcus Priesch)
+- fixed retirement of items in rdbms imports (sf bug 841355)
+- fixed bug in looking up journal of newly-created items in *dbm backends
+
+
+2003-09-29 0.6.2
+Fixed:
+- cleaned up, clarified internal caching API in *dbm backends
+- stopped pyc writing to current directory! yay! (patch 800718 with changes)
+- fixed file leak in detector initialisation (patch 800715)
+- commented out example tracker homes (patch 800720)
+- added note about hidden :template var in user.item (bug 799842)
+- fixed Apply Error that was raised, when property was deleted from class and
+  we are trying to edit an instance
+
+
+2003-08-31 0.6.1
+Fixed:
+- Add note about installing cgi-bin with a different interpreter
+- Importing wasn't setting None values explicitly when it should have been
+- Fixed import warning regarding 0xffff0000 literal, finally, really this
+  time. Checked on win2k. (sf bug 786711)
+- fix CGI editCSV action to handle metakit's integer itemids
+- apply fix for "remove" links from Klamer Schutte
+- added permission check on "remove" link while I was there..
+- applied CSV fix for python2.3 (sf bug 790363)
+- fixed form padding in LHS menu (sf bug 790502)
+- fixed upgrading docs for timezones (sf bug 790498)
+- set the content type on page templates (can have XML templates now)
+- various cosmetic fixes (thanks James Kew for being persistent :)
+- applied patch 739314 (sorry John!)
+
+
+2003-08-08 0.6.0
+- Fixed editing attributes on FileClass nodes.
+- Query editing now works correctly (sf bug 621248)
+- roundup-server now logs IP addresses by default (sf bug 778795)
+- logfile must be specified if pidfile is (sf bug 772820)
+- timelog editing via csv interface crashes (sf bug 699837)
+- sort multilinks a little better for grouping (sf bug 772935)
+- batch the (list) listings at 500 entries per page (sf bug 759906)
+- don't have RDBMS backends list retired nodes (sf bug 767319)
+- fix file downloading
+- add action attribute to issue.item form tag
+
+
+2003-07-29 0.6.0b4
+- plugged cross-site-scripting hole (thanks Jeff Epler)
+- handle deprecation of FCNTL in python2.2+ (sf bug 756756)
+- handle missing Subject: line (sf bug 755331)
+- fix New User creation (sf bug 754510)
+- fix hackish message escaping (sf bug 757128)
+- fix :required ordering problem (sf bug 740214)
+- audit some user properties for valid values (roles, address) (sf bugs
+  742968 and 739653)
+- fix HTML file detection (hence history xref linking) (sf bug 741478)
+- session database caches it's type, rather than calling whichdb each time 
+  around.
+- changed rdbms_common to fix sql backends for new Boolean types under Py2.3
+
+
+2003-06-10 0.6.0b3
+Fixed:
+- cgi client was broken during b2 fixing
+
+
+2003-06-09 0.6.0b2
+Feature:
+- added the start/stop/restart/condstart/status roundup-server control
+  script
+
+Fixed:
+- handle non-existant demo dir (thanks Ollie Rutherfurd)
+- strip whitespace from Role names so "User, Admin" will work
+- fixed template searching on Windows (thanks J Vickroy)
+
+
+2003-05-09 0.6.0b1
+Removed:
+- having served its purpose as a template for other relational database
+  implementations, the gadfly backend has now been removed from the Roundup
+  distribution.
+
+Feature:
+- new instant-gratification Demo Mode
+- support setting of properties on message and file through web and
+  email interface (thanks John Rouillard)
+- allow additional control over the roundupdb email sending (explicit
+  cc addresses, different from address and different nosy list property)
+  (thanks John Rouillard)
+- applied patch for nicer history display (sf feature 638280)
+- cleaning old unused sessions only once per hour, not on every cgi 
+  request. It is greatly improves web interface performance, especially
+  on trackers under high load
+- added mysql backend (see doc/mysql.txt for details)
+- switch metakit to use "compressed" multilink journal change representation
+- metakit now handles "unset" for most types (not Number and Boolean)
+- fixed bug in metakit search-by-ID
+- added ability to display localized dates in web interface. User input is
+  convered to GMT (see doc/upgrading.txt).
+- added a form to show a specific issue
+- more proper sorting/grouping on mulitilink properties. Sorting is performed
+  not only by number of links, but also by links itself. This makes usable
+  grouping e.g. by topic multilink
+- add "ago" to intervals in the past (sf bug 679232)
+- included UN*X manual pages from Bastian Kleineidam
+- implemented extension to form parsing to allow editing of multiple items
+  and creation of multiple items (but only one per class)
+- the colon ":" special form variable designator may now be any of : + @
+- trackers' templates directory can contain subdirectories with static files
+  (e.g. images). They are accessible naturally: _file/images/img.gif
+- altered Class.create() and FileClass.create() methods to make "content"
+  property available in auditors
+- can now configure CC to author only for messages creating issues (sf
+  feature 625808)
+- registration is now a two-step process, with confirmation from the email
+  address supplied in the registration form
+- added password reset feature for forgotten password / login
+- added support for last-modified and if-modified-since headers for static
+  file serving
+- added Node.get() method
+- nicer page titles (sf feature 65197)
+- relaxed CVS importing (sf feature 693277)
+- added support for searching on ranges of dates and intervals (see
+  doc/user_guide.txt in chapter "Searching Page" for details) (closes sf
+  feature 700178)
+- role names made case insensitive
+- added ability to restore retired nodes
+- more lenient date input and addition Interval input support (sf bug 677764)
+- roundup mailgw now handles apop
+- implemented ability to search for multilink properties with no value
+- Class.find() may now find unset Links (sf bug 700620)
+- more flexibility in classhelp link labelling (sf feature 608204)
+- added command-line functionality for roundup-admin (sf feature 687664)
+- added nicer popup windows for topic, nosy, etc (has add/remove buttons)
+  thanks Gus Gollings
+- HTML templating files now have a .html extension
+- Roundup templates are now distributed much more sanely, allowing for
+  3rd-party templates.
+- extended date syntax to make range searches even more useful
+- SMTP login and TLS support added (sf bug 710853 with extras ;)
+  Note: requires python 2.2+
+- added Windows Service mode for roundup-server when daemonification is
+  attempted on Windows.
+- sort HTMLClass.properties results by name (sf feature 724738)
+- nicer index navigation (sf feature 676866)
+
+Fixed:
+- applied unicode patch. All data is stored in utf-8. Incoming messages
+  converted from any encoding to utf-8, outgoing messages are encoded 
+  according to rfc2822 (sf bug 568873)
+- fixed layout issues with forms in sidebar
+- fixed timelog example so it handles new issues (sf bug 678908)
+- handle missing os.fork() (sf bug 681046)
+- added warning filter for "FutureWarning: hex/oct constants > sys.maxint will
+  return positive values..." (literal 0xffff0000 in portalocker.py)
+- fixed ZPT code generating SyntaxWarning for assignment to None
+- open static files using binary mode (sf bug 693208)
+- fixed deja-vu bug 692910
+- don't display "Editing" on read-only pages (sf bug 651967)
+- re-worked detectors initialisation - woohoo, no more cross-importing!
+- fixed export/import of retired nodes (sf bug 685273)
+- remember the display template specified during edit (sf bug 701815)
+- added example HTML tempating for vacation flag (sf bug 701722)
+- finally, tables autosize columns (sf bug 609070)
+- added creation to index columns (sf bug 708247)
+- fixed missing (pre-commit) journal entries in *dbm backends (sf bug 679217)
+- URL cited in roundup email confusing dumb Email clients (sf bug 716585)
+- set title on issues even when the email body is empty (sf bug 727430)
+- under the heading of "questionable whether it's a fix or not"
+  (sf "bug" 621226 for the users of the "standards compliant" browser IE)
+
+
+2003-05-08 0.5.7
+- fixed Interval maths (sf bug 665357)
+- fixed sqlite rollback/caching bug (sf bug 689383)
+- fixed rdbms table update detection logic (sf bug 703297)
+- fixed detection of bad date specs (sf bug 691439)
+- required String properties not being flagged (thanks Ajit George)
+- only look for CSV files when importing (thanks Dan Grassi)
+- can now unset values in CSV editing (sf bug 704788)
+- fixed rdbms email address lookup (case insensitivity)
+- email file attachments added to issue files list (sf bug 711501)
+- added socket timeout to attempt to prevent stuck processes (sf bug 665487)
+- email registered users shouldn't be able to log in (sf bug 714673)
+- handle missing addresses on users (sf bug 724537)
+
+
+2003-02-27 0.5.6
+- fixed templating filter function arguments (sf bug 678911)
+- fixed multiselect in searching (sf bug 676874)
+- fixed parsing of content-disposition filenames (sf bug 675116)
+- added 'h' to roundup-server optarg list (sf bug 674070)
+- fixed doc for db.history in anydbm and rdbms_common (sf bug 679221)
+- fixed roundup-reminder (sf bug 681042)
+- fixed int assumptions about Number values (sf bug 677762)
+- clarified licensing
+- another attempt to fix cookie misbehaviour - customise cookie name using
+  tracker name
+- fixed error in indexargs_url (thanks Patrick Ohly)
+- fixed getnode (sf bug 684531)
+- fixed args to some date templating methods (sf bug 689670)
+- fixed database corruption in rdbms property mutation
+
+
+2003-01-24 0.5.5
+- fixed rdbms searching by ID (sf bug 666615)
+- fixed metakit searching by ID
+- detect corrupted index and raise semi-useful exception (sf bug 666767)
+- open server logfile unbuffered
+- revert StringHTMLProperty to not hyperlink text by default
+- fixes to CGI form handling
+- fix unlink bug in metakit backend
+- fixed hyperlinking ambiguity (sf bug 669777)
+- fixed cookie path to use TRACKER_WEB (sf bug 667020) (thanks Nathaniel Smith
+  for helping chase it down and Luke Opperman for confirming fix)
+
+
+2003-01-10 0.5.4
+- key the templates cache off full path, not filename
+- implemented whole-database locking
+- hyperlinking of special text (url, email, item designator) in messages
+- fixed time default in date.py
+- fixed error in cgi/templates.py (sf bug 652089)
+- fixed handling of missing password (sf bug 655632)
+- applied patches for handling Outlook quirks (thanks Andrey Lebedev)
+  (multipart/alternative, "fw" and content-type "name")
+- fire auditors and reactors in rdbms retire (thanks Sheila King)
+- better match for mailgw help "command" text
+- handle :add: better in cgi form parsing (sf bug 663235)
+- handle all-whitespace multilink values in forms (sf bug 663855)
+- fixed searching on date / interval fields (sf bug 658157)
+- fixed form elements names in search form to allow grouping and sorting 
+  on "creation" field
+- display of saved queries is now performed correctly
+
+
+2002-12-11 0.5.3
+- added mention of how to give users multiple Roles
+- mention needed trailing "/" in TRACKER_WEB
+- fixed upgrading doc to have CGI changes in the correct order
+- fixed double-close of anydbm backend (sf bug 639030)
+- removed use of string/strop from TAL/TALInterpreter
+- handle KeyboardInterrupt nicely
+- fixed Date and Interval form value handling
+- fixed Date.local()
+- email quoted text stripping is controllable again (sf bug 650742)
+- extract attachment name from content-disposition if name is missing (sf
+  bug 637278)
+- removed FILTER_POSITION from bundled configs
+- reverse message listing in issue display (reversion of recent change)
+- bad entries for multilink editing in cgi don't traceback now (sf bug 640310)
+- detect and break email loops (sf bug 640854)
+- finished of handling of retired flag in filter() (sf bug 635260)
+- allow StringHTMLProperty in MultilinkHTMLProperty test to work
+- don't set explicit None Link properties in web create
+- fixed nasty sorting bug that was lowercasing properties
+- allow multiple :remove and :add elements per property being edited
+- added date header to emails (sf bug 651358)
+
+
+2002-11-07 0.5.2
+- added quotes around python interpreter in windows bat (sf bug 623963)
+- fixed link at end of installation doc (sf bug 623957)
+- handle "classname" URL path errors cleaner (generate a 404)
+- added CGI :remove:<propname> and :add:<propname> which specify item ids to
+  remove / add in <propname> multilink.
+- bugfix in boolean templating
+- remember the change note on bad submissions (sf bug 625989)
+- highlight required form fields (sf bug 625989)
+- force non-word boundary to match re: in subject (sf bug 626303)
+- handle sqlite bug (<2.7.2) (sf bug 630828)
+- handle missing props in anydbm stringFind
+- updated email package address formatting (deprecation)
+- copied email address quoting from email v2.4.3 so we're consistent with 2.2
+- email summary extraction now takes the first whole sentence or line -
+  whichever is longer
+- documented dependency on Active State (sf bug 623959)
+- ensured there's no zero-length files in source (sf bug 633622)
+- added ID to the search page (sf bug 631601)
+- fixed filtering by id in anydbm
+- show issue ID in the headings (sf bug 631598)
+- show entire messages by default in issues (sf bug 625995)
+- fixed journalling to save old values instead of new (sorry it took so long GM)
+- handle missing REQUEST_URI for cgi-bin users (sf bug 620163)
+
+
+2002-10-16 0.5.1
+- highlight rows in groups of three
+- metakit cleanups
+- nicer "navigation" style in index views
+- handle missing Link values in anydbm backend set() operation
+- fixed filter() with no sort/group (sf bug 618614)
+- fixed register with no session (sf bug 618611)
+- fixed log / pid file path handling in roundup-server (sf bug 617981)
+- fixed old gadfly compatibiltiy problem, for sure this time (sf bug 612873)
+- https URLs from config now recognised as valid (sf bug 619829)
+- nicer display of tracker list in roundup-server (sf bug 619769)
+- fixed some missed renaming instance -> tracker (sf bug 619769)
+- allow blank passwords again (sf bug 619714)
+- expose the tracker config as a variable for templating
+- homogenise newlines in CGI text submissions (sf bug 614072)
+- merged Zope Collector #372 fix from ZPT CVS trunk
+- fixed history to display username instead of userid
+- shipped templates didn't import all hyperdb types in dbinit.py
+- fixed bug in Interval serialisation
+- handle "unset" status in status auditor (sf bug 621250)
+- issues in 'done-cbb' are now also moved to 'chatting' on new messages
+- implemented the missing Interval.__add__
+- added ability to implement new templating utility methods
+- expose the Date.pretty method to templating
+- made form table cell alignment consistent (sf bug 621887)
+- include stylesheet in docs (sf bug 623183)
+- store PIPE messages so we can re-send them on errors (sf bug 623082)
+- implemented "retire" cgi action, added to user index (sf bug 618612)
+- included doc ideas from Bernhard Reiter (sf feature 621941)
+
+
+2002-10-02 0.5.0
+- fixed style for alternating rows in user lists
+- fixed query edit form so it doesn't barf
+- #617133 ] 0.5.0pr1 uses nonexistent renderTemplate
+- merged Zope Collector #539 fix from ZPT CVS trunk
+
+
+2002-09-27 0.5.0 pr1
+- handling of None for Date/Interval/Password values in export/import
+- handling of journal values in export/import
+- password edit now has a confirmation field
+- registration error punts back to register page
+- gadfly backend now handles changes to the schema - but only one property
+  at a time
+- cgi.client base URL is now obtained from the config TRACKER_WEB
+- request.url has gone away - there's too much magic in trying to figure
+  what it should be
+- cgi-bin script redirects to https now if the request was https
+- FileClass "content" property wasn't being returned by getprops() in most
+  backends
+- we now verify instance attributes on instance open and throw a useful error
+  if they're not all there
+- sf 611217 ] menu() has problems when labelprop==None
+- verify contents of tracker module when the tracker is opened
+- many performance improvements in *dbm and sql backends
+- mailgw was missing an "import sys"
+- setup now installs scripts with python -O flag, doubling performance in some
+  cases (there's a lot of __debug__ use)
+- fix :required for Link menus
+- import wasn't setting the ID to maxid+1
+- added getItem to HTMLClass so you can access arbitrary items in templates
+- index filtering form values may now be key values too
+- replaced the content() callback ickiness with Page Template macro usage
+- changed the default CSS style to be less offensive to some ;)
+- better handling of Page Template compilation errors
+- handle multiple unrelated indexed classes
+- #614188 ] Exception in mailgw.py
+- #613310 ] traceback on onexistant items
+- #613291 ] typos in nosy list
+- handle stupid mailers that QUOTE their Re; 'Re: "[issue1] bla blah"'
+- giving a user a Role that doesn't exist doesn't break stuff any more
+- revamped user guide, customisation guide, added maintenance guide
+- merge Zope Collector #538 fix from ZPT CVS trunk (path expressions with a
+  non-path final alternate no longer try to call a value returned by that
+  alternate)
+- merge Zope Collector #573 fix from ZPT CVS trunk
+- merge Zope Collector #580 fix from ZPT CVS trunk
+- added "crypt" password encoding and ability to set password with
+  already encrypted password through roundup-admin
+- fixed the mailgw so that anonymous users may still access it
+- add hook to allow external password verification, overridable in the
+  tracker interfaces module
+- fixed login attempt by user that doesn't exist
+
+
+2002-09-13 0.5.0 beta2
+-  all backends now have a .close() method, and it's used everywhere
+-  fixed bug in detectors __init__
+-  switched the default issue item display to only show issue summary
+   (added instructions to doc to make it display entire content)
+-  MANIFEST.in was missing a lot of template files
+-  added generic item editing
+-  much nicer layout of template rendering errors
+-  added context/is_edit_ok and context/is_view_ok convenience methods and
+   implemented use of them in the classic template
+
+
+2002-09-11 0.5.0 beta1
+Fixed:
+-  #576086 ] dumb copying mistake (frontends/ZRoundup.py)
+-  installation instructions now mention "python2" in "testing your python".
+-  made the unit tests run again - they were quite b0rken
+-  #571170 ] gdbm deadlock
+-  #576241 ] MultiLink problems in parsePropsFromForm
+-  fixed the date module so that Date(". - 2d") works
+-  web forms may now unset Link values (like assignedto)
+-  cleanup: moved roundup.templatebuilder to roundup.templates.builder
+-  instance __init__ no longer silently traps dbinit import errors
+
+Feature:
+-  new backend for metakit (thanks Gordon McMillan)
+-  new backend for gadfly (it's as done as it's going to get)
+-  further split the dbm backends from the core code, allowing easier
+   non-dict-like backends (eg metakit, RDB)
+-  implemented and used the new access control mechanisms (Permissions, Roles)
+   (see doc/security.txt)
+-  switched templating to use Zope's PageTemplates (yay!)
+-  switched to sessions for web authentication
+-  added Boolean and Number types
+-  fixed the journal bloat
+-  updated design document for new access controls
+-  updated customisation document, including more examples
+-  entire database export and import (incl files)
+-  better mailgw help message (feature request #558562)
+-  re-enabled link backrefs from messages (feature request #568714)
+-  the page layout is now templatable
+-  re-worked cgi interface to abstract out the explicit "issue" interface
+-  have index page handle mid-page errors better so header and footer are
+   still visible
+-  we handle "not found", access and item page render errors better
+-  fixed double-submit by having new-item-submit redirect at end
+-  daemonify roundup-server (fork, logfile, pidfile)
+-  modify cgitb to display PageTemplate errors better
+-  rename to "instance" to "tracker"
+-  have roundup.cgi pick up tracker config from the environment 
+-  revamped look and feel in web interface
+-  cleaned up stylesheet usage
+-  several bug fixes and documentation fixes
+-  added is_retired test to hyperdb.Class
+-  added capability to save queries:
+   - a query Class with name, klass (to search) and url (query string)
+     properties
+   - a Multilink to query on user called queries
+   - html templates for query, and a list of queries in user.item
+   - search form has Save button & name input
+   - saved queries put in menu in pagehead
+   - for migration, none of the above is required and old behavior preserved.
+   - showquery translates search form <-> query string
+-  cleaned up the indexer code:
+   - it splits more words out
+   - removed code we'll never use (roundup.roundup_indexer has the full
+     implementation, and replaces roundup.indexer)
+   - only index text/plain and rfc822/message (ideas for other text formats to
+     index are welcome)
+   - added simple unit test for indexer. Needs more tests for regression.
+   - all String properties may now be indexed too. Currently there's a bit of
+     "issue" specific code in the actual searching which needs to be
+     addressed. In a nutshell:
+     + pass 'indexme="yes"' as a String() property initialisation arg, eg:
+           file = FileClass(db, "file", name=String(), type=String(),
+               comment=String(indexme="yes"))
+     + the comment will then be indexed and be searchable, with the results
+       related back to the issue that the file is linked to
+   - as a result of this work, the FileClass has a default MIME type that may
+     be overridden in a subclass, or by the use of a "type" property as is
+     done in the default templates.
+   - the regeneration of the indexes (if necessary) is done once the schema is
+     set up in the dbinit.
+   - new "reindex" command in roundup-admin used to force regeneration of the 
+     index
+-  added email display function - mangles email addrs so they're not so easily
+   scraped from the web
+-  switched to using a session-based web login
+-  made mailgw handle set and modify operations on multilinks (bug #579094)
+-  fixed the journal bloat from multilink changes - we just log the add or
+   remove operations, not the whole list
+
+
+2002-06-24 0.4.2
+Fixed:
+-  Cleaned up the hyperdb unit tests.
+-  Applied patch from Andrew W. Nosenko to give nicer Unauthorised message
+   when anonymous user tries to edit. Should've been applied in 0.4.2pr1. Oops.
+-  Added more detailed note to MIGRATION regarding the detectors changes.
+
+
+2002-06-19 0.4.2pr1
+Feature:
+-  added a "detectors" directory for people to put their useful auditors and
+   reactors in. Note - the roundupdb.IssueClass.sendmessage method has been
+   split and renamed "nosymessage" specifically for things like the nosy
+   reactor, and "send_message" which just sends the message.
+-  link() htmltemplate function now has a "showid" option for links and
+   multilinks. When true, it only displays the linked node id as the anchor
+   text. The link value is displayed as a tooltip using the title anchor
+   attribute.
+   To use in eg. the superseder field, have something like this:
+   <td>
+    <display call="field('superseder', showid=1)">
+    <display call="classhelp('issue', 'id,title', label='list', width=500)">
+    <property name="superseder">
+     <br>View: <display call="link('superseder', showid=1)">
+    </property>
+   </td>
+-  stripping of the email message body can now be controlled through the
+   config variables EMAIL_KEEP_QUOTED_TEXT and EMAIL_LEAVE_BODY_UNCHANGED.
+-  all database files created are now group readable and writable.
+-  added option to automatically add the authors and recipients of messages
+   to the nosy lists with the options ADD_AUTHOR_TO_NOSY (default 'new') and
+   ADD_RECIPIENTS_TO_NOSY (default 'new'). These settings emulate the current
+   behaviour. Setting them to 'yes' will add the author/recipients to the nosy
+   on messages that create issues and followup messages.
+-  reverting to dates for intervals > 2 months sucks
+-  changed the default message list in issues to display the message body
+-  applied patch #558876 ] cgi client customization
+-  split instance initialisation into two steps, allowing config changes
+   before the database is initialised.
+-  don't create an empty message on email issue creation if the email is empty
+-  may now display additional fields in Multilink form menus
+-  #541941 ] changing multilink properties by mail
+-  #526730 ] search for messages capability
+-  #505180 ] split MailGW.handle_Message
+   - also changed cgi client since it was duplicating the functionality
+
+Fixed:
+-  stop sending blank (whitespace-only) notes
+-  cleanup of serialisation for database storage
+-  node ids are now generated from a lockable store - no more race conditions
+-  sorting was applied to all nodes of the MultiLink class instead of
+   to the nodes that are actually linked to in the "field" template
+   function.  This adds about 20+ seconds in the display of an issue if
+   your database has a 1000 or more issues in it.
+-  added missing documentation for a few of the config option values
+-  file upload broke if you didn't supply a change note
+-  fixed SCRIPT_NAME in ZRoundup for instances not at top level of Zope
+   (thanks dman)
+-  fixed some sorting issues that were breaking some unit tests under py2.2
+-  mailgw test output dir was confusing the init test (but only on 2.2 *shrug*)
+-  node caching now works, and gives a small boost in performance
+-  #449374 ] re-enable bsddb3 backend
+   bsddb3 backend now works, reinstating
+-  #551483 ] assignedto in Client.make_index_link
+-  made backends.__init__ be more specific about which ImportErrors it really
+   wants to ignore
+-  fixed the example addresses in the templates to use correct example domains
+-  cleaned out the template stylesheets, removing a bunch of junk that really
+   wasn't necessary (font specs, styles never used) and added a style for 
+   message content
+-  build htmlbase if tests are run using CVS checkout
+-  #565979 ] code error in hyperdb.Class.find
+-  #565996 ] The "Attach a File to this Issue" fails
+-  #564271 ] find() and new properties
+-  #562130 ] cookie path generated from ZRoundup was wrong in some situations
+-  remove CR characters embedded in messages (ZRoundup)
+-  properly quote the email address and "real name" in all situations using the
+    'email' module if it is available and 'rfc822' otherwise
+-  #565992 ] if ISSUE_TRACKER_WEB doesn't have the trailing '/', add it
+-  use the rfc822 module to ensure that every (oddball) email address and
+   real-name is properly quoted
+-  #558867 ] ZRoundup redirect /instance requests to /instance/ 
+-  #569415 ] {version}
+-  #569178 ] type error
+   was fixed as part of the general cleanup of reactors
+
+
+2002-03-25 - 0.4.1
+Feature:
+-  use blobfiles in back_anydbm which is used in back_bsddb.
+   change test_db as dirlist does not work for subdirectories.
+   ATTENTION: blobfiles now creates subdirectories for files.
+-  add module blobfiles in backends with file access functions.
+-  roundup db catch only IOError in getfile.
+-  roundup db catches retrieving not existing files.
+-  #503204 ] mailgw needs a default class
+   - partially done - the setting of additional properties can wait for a
+     better configuration system.
+-  Alternate email addresses are now available for users. See the MIGRATION
+   file for info on how to activate the feature.
+-  #511168 ] Web interface: Adding new products
+   Classes that don't provide template html get a default edit interface now:
+   - access using the admin "class list" interface
+   - limited to admin-only
+   - requires the csv module from object-craft (url given if it's missing)
+-  Added popup help for classes using the classhelp html template function.
+   - add <display call="classhelp('priority', 'id,name,description')">
+     to an item page, and it generates a link to a popup window which displays
+     the id, name and description for the priority class. The description
+     field won't exist in most installations, but it will be added to the
+     default templates.
+-  #517734 ] web header customisation is obscure
+-  All messages sent to the nosy list are now encoded as
+   quoted-printable before they are sent.
+-  Fixed display of mutlilink properties when using the template
+   functions, menu and plain.
+
+Fixed:
+-  Clean up mail handling, multipart handling.
+-  respect encodings in non multipart messages.
+-  makeHtmlBase: re.sub under python 2.2 did not replace '.', string.replace
+   does it.
+-  preamble in tepmlateBuilder mentioned htmldata
+-  mailgw checks encoding on first part too.
+-  #511586 ] unittest FAIL: testReldate_date
+-  Added a uniquely Roundup header to email, "X-Roundup-Name"
+-  All forms now have "double-submit" protection when Javascript is enabled
+   on the client-side.
+-  #516883 ] mail interface + ANONYMOUS_REGISTER
+-  #516854 ] "My Issues" and redisplay
+-  #517906 ] Attribute order in "View customisation"
+-  #514854 ] History: "User" is always ticket creator
+-  wasn't handling cvs parser feeding correctly
+-  fixed some problems in date calculations (calendar.py doesn't handle over-
+   and under-flow). Also, hour/minute/second intervals may now be more than
+   99 each.
+-  #527416 ] roundup-admin uses undefined value
+-  #527503 ] unfriendly init blowup when parent dir
+   (also handles UsageError correctly now in init)
+-  #524129 ] roundup-admin gets python path wrong
+
+
+2002-01-24 - 0.4.0
+Feature:
+-  much nicer history display (actualy real handling of property types etc)
+-  journal entries for link and mutlilink properties can be switched on or 
+   off
+-  properties in change note are now sorted
+-  you can now use the roundup-admin tool pack the database
+
+Fixed:
+-  the mail gateway now responds with an error message when invalid values 
+   for arguments are specified for link or mutlilink properties
+-  modified unit test to check nosy and assignedto when specified as arguments
+-  handle attachments with no name (eg tnef)
+-  fixed setting nosy as argument in subject line
+-  fixed back_bsddb so it passed the journal tests
+-  fixed status changes in mail gateway (eg. unread -> chatting)
+-  we'll actually distribute the frontends directory now, as advertised...
+-  handle stripping of "AW:" from subject line
+-  htmltemplate list() wasn't sorting...
+-  unit tests for html templating (and re-enabled the listbox field for
+   multilinks)
+-  allow abbreviation of "help" in admin tool too.
+-  run_tests testReldate_date failed if LANG is 'german'
+-  mailgw failures (unexpected ones) are forwarded to the roundup admin
+
+
+2002-01-16 - 0.4.0b2
+Fixed:
+-  #495392 ] empty nosy -patch
+-  #500574 ] messageid must have format <part1 at part2>
+-  fixed some problems with web editing and change detection
+-  mail splitting wasn't detecting responses in the same "section" as quoted
+   text
+-  missed a "from i18n import _" in date.py
+-  #501690 ] MIGRATION.txt incomplete
+-  #502342 ] pipe interface
+-  #502437 ] rogue reactor and unittest
+-  re-enabled dumbdbm when using python >2.1.1 (ie 2.1.2, 2.2)
+-  changed all config accesses so they access either the instance or the
+   config attriubute on the db. This means that all config is obtained from
+   instance_config instead of the mish-mash of classes. This will make
+   switching to a ConfigParser setup easier too, I hope.
+-  #502951 ] adding new properties to old database
+-  #502953 ] nosy-like treatment of other multilinks
+-  #503164 ] create and passwords
+-  plain rendering of links in the htmltemplate now generate a hyperlink to
+   the linked node's page.
+-  #503330 ] ANONYMOUS_REGISTER now applies to mail
+-  #503353 ] setting properties in initial email
+-  #502956 ] filtering by multilink not supported
+-  #503340 ] creating issue with [asignedto=p.ohly]
+-  #502949 ] index view for non-issues and redisplay
+-  #503793 ] changing assignedto resets nosy list
+-  lots of date/interval related changes:
+   - more relaxed date format for input
+   - handle None for date/interval properties
+
+
+2002-01-08 - 0.4.0b1
+Feature:
+-  Added INSTANCE_NAME to configuration - used in web and email to identify
+   the instance.
+-  Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup
+   signature info in e-mails.
+-  Some more flexibility in the mail gateway and more error handling.
+-  Login now takes you to the page you back to the were denied access to.
+-  Admin user now can has a user index link on their web interface.
+-  We now have basic transaction support. Information is only written to
+   the database when the commit() method is called. Only the anydbm and
+   bsddb3 backends are modified in this way - the bsddb3 backend needs a
+   lot more work anyway...
+    - the CGI and mailgw automatically commit() at the end of processing a
+      single transaction
+    - the admin tool requires an explicit "commit" - it will prompt at exit
+      if there are unsaved changes. A "rollback" removes all changes made
+      during the session (up to the last commit).
+-  Added the "display" command to the admin tool - displays a node's values
+-  Message author's name appears in From: instead of roundup instance name
+   (which still appears in the Reply-To:)
+-  Added a Zope frontend for roundup.
+-  Centralised the python version check code, bumped version to 2.1.1 (really
+   needs to be 2.1.2, but that isn't released yet :)
+-  much better attaching of erroneous messages in the mail gateway
+-  #496356 ] Use threading in messages
+   This adds the tracking of messages by message-id and allows threading
+   using in-reply-to. Most e-mail clients support threading using this
+   feature, and we hope to add support for it to the web gateway.
+
+Fixed:
+-  Lots of bugs, thanks Roché and others on the devel mailing list!
+-  login_action and newuser_action return values were being ignored
+-  Woohoo! Found that bloody re-login bug that was killing the mail
+   gateway.
+-  Fixed login/registration forwarding the user to the right page (or not,
+   on a failure)
+-  We now use weakrefs in the Classes to keep the database reference, so
+   the close() method on the database is no longer needed.
+-  #487480 ] roundup-server
+-  #487476 ] INSTALL.txt
+-  #489760 ] [issue] only subject
+-  fixed doc/index.html to include the quoting in the mail alias.
+-  fixed the backends __init__ so we can pydoc the backend modules
+-  web i/f reports "note added" if there are no changes but a note is entered
+-  we were assuming database files created by anydbm had the same name, but
+   this is not the case for dbm. We now perform a much better check _and_
+   cope with the anydbm implementation module changing too!
+-  envelope-from is now set to the roundup-admin and not roundup itself so
+   delivery reports aren't sent to roundup (thanks Patrick Ohly)
+-  #495400 ] entering blanks
+   Values with spaces are now accepted in roundup-admin - check the long help
+   for details.
+-  #496360 ] table width does not work
+-  detectors were being registered multiple times
+-  added tests for mailgw
+
+
+2001-11-23 - 0.3.0 
+Feature:
+-  #467129 ] Lossage when username=e-mail-address
+-  #473123 ] Change message generation for author
+-  MailGW now moves 'resolved' to 'chatting' on receiving e-mail for an issue.
+-  Added Structured Text rendering to htmltemplate, thanks Brad Clements.
+-  Added CGI configuration via env vars (see roundup.cgi for details)
+-  "roundup.cgi" is now installed to "<python-prefix>/share/roundup/cgi-bin"
+-  roundup-admin now accepts abbreviated commands (eg. l = li = lis = list)
+-  roundup-mailgw now supports unix mailbox and POP as sources of mail.
+-  roundup-admin now handles all hyperdb exceptions
+-  users may attach files to issues (and support in ext) through the web now
+-  incorporated patch from Roch'e Compaan implementing attachments in nosy
+   e-mail
+-  added a target version field to the extended issue schema
+-  added dummy hooks for I18N and some preliminary (test) markup of
+   translatable messages
+
+Fixed:
+-  Fixed a bug in HTMLTemplate changes.
+-  'unread' to 'chatting' automagic status change was b0rken.
+-  Anonymous user lockout wasn't working.
+-  roundup-server now works on Windows, thanks Juergen Hermann.
+-  Fixed install documentation, also thanks Juergen Hermann.
+-  Fixed some URL issues in roundup.cgi, again thanks Juergen Hermann.
+-  bug #475347 ] WindowsError still not caught (patch from Juergen Hermann)
+-  bug #474749 ] indentations lost
+-  bug #477104 ] HTML tag error in roundup-server
+-  bug #477107 ] HTTP header problem
+-  bug #477687 ] conforming html
+-  bug #474372 ] Netscape 4.77 do not render Support form
+-  bug #477685 ] base64.decodestring breaks
+-  bug #477837 ] lynx does not like the cookie
+-  bug #477892 ] Password edit doesn't fix login cookie
+-  newuser_action now presents error messages rather than tracebacks.
+-  bug #479511 ] mailgw to pop
+-  bug #479508 ] roundup-admin crash on wrong class
+-  bad error report in hyperdb
+-  roundup.mailgw now handles errors on the set() and create() at the end
+   of processing
+-  roundup.mailgw also handles messages that are passed to it that don't
+   contain a From: line - apparently some POP servers can do this. It punts
+   an error message to the roundup admin.
+-  fixed nosy reaction and author copy handling
+-  errors in nosy reaction will be propogated now (were effectively being
+   squashed)
+-  re-open the database as the author in mail handling
+-  missing "return" in filter_section (thanks Roch'e Compaan)
+
+
+2001-10-23 - 0.3.0 pre 3
+Feature:
+-  MailGW now moves 'unread' to 'chatting' on receiving e-mail for an issue.
+-  feature #473127: Filenames. I modified the file.index and htmltemplate
+   source so that the filename is used in the link and the creation
+   information is displayed.
+ Admin Tool (roundup-admin):
+ -  Interactive mode for running multiple (independant at present) commands.
+ -  Tabular display of nodes.
+ -  Import and export via colon-separated files.
+
+Changed:
+-  re-organised the html templating code. Fixed some bugs, probably
+   introduced some more.  Hopefully not too many.
+
+Fixed:
+-  Stand-alone server now has a configurable setuid user.
+-  CGI interface wasn't handling checkboxes at all.
+-  Fixed quopri usage in mailgw from bug reports on mailing list.
+-  Remove the "freshen" command from the roundup-admin tool.
+-  Catch errors in login - no username or password supplied.
+-  Fixed editing of password (Password property type) thanks Roch'e Compaan.
+-  Fixed grouping of non-str properties thanks Roch'e Compaan.
+-  bug #473121: The customisation view and filters (CGI interface view
+   customisation section may now be hidden (patch from Roch'e Compaan.)
+-  bug #473122: Issue id sorting (hyperdb sorts strings-that-look-like-numbers
+   as numbers now.
+-  bug #473124: UI inconsistency with Link fields.
+   This also prompted me to fix a fairly long-standing usability issue -
+   that of being able to turn off certain filters.
+-  bug #473125: Paragraph in e-mails
+-  bug #473126: Sender unknown
+-  bug #473130: Nosy list not set correctly
+
+
+2001-10-11 - 0.3.0 pre 2
+Fixed:
+-  Hyperdatabase was inserting empty strings instead of None for missing
+   property values. This broke a lot of things.
+
+
+2001-10-10 - 0.3.0 pre 1
+Feature:
+-  roundup-admin create now prompts for property info if none is supplied
+   on the command-line.
+-  hyperdb Class getprops() method may now return only the mutable
+   properties.
+-  CGI interfaces now generate a top-level index of their known instances.
+
+Changed:
+-  Login now uses cookies, which makes it a whole lot more flexible. We can
+   now support anonymous user access (read-only, unless there's an
+   "anonymous" user, in which case write access is permitted). Login
+   handling has been moved into cgi_client.Client.main()
+-  The "extended" schema is now the default in roundup init.
+-  The schemas have had their page headings modified to cope with the new
+   login handling. Existing installations should copy the interfaces.py
+   file from the roundup lib directory to their instance home.
+-  Passwords are now encoded by default (except exising databases which
+   will only be encoded when the passwords are changed). The scheme used
+   at the moment is SHA - but the code is flexible enough to take any
+   number of encoding systems.
+-  The roundup-admin tool always operates as the "admin" user now. Database
+   protection should be achieved using file system protections (see the
+   documentation for details.)
+
+Fixed:
+-  Incorrectly had a Bizar Software copyright on the cgitb.py module from
+   Ping - has been removed.
+-  Pretty time interval wasn't handling > 1 month properly.
+-  Generation of links to Link/Multilink in indexes. (thanks Hubert Hoegl)
+-  AssignedTo wasn't in the "classic" schema's item page.
+-  Fixed a whole bunch of places in the CGI interface where we should have
+   been returning Not Found instead of throwing an exception.
+-  Fixed a deviation from the spec: trying to modify the 'id' property of
+   an item now throws an exception.
+-  The plain() template function now html-escapes the content.
+-  Change message was stuffing up for multilinks with no key property.
+
+
+
+--------------
+
+2001-08-30 - 0.2.8
+Fixed:
+-  Wasn't handling unguessable mime types for file uploads.
+-  Missing import in mailgw.
+
+
+2001-08-29 - 0.2.7
+Feature:
+-  Text searches are now case insensitive. All forms of text search use
+   regular expressions now.
+
+Fixed:
+-  Had another 2.1-ism in the unit tests
+-  Made the mail parser a little more robust w.r.t missing Subject:
+   (both thanks Mikhail Sobolev)
+-  Missed some isFooType usages (thanks Mikhail Sobolev for spotting them)
+-  Reverted back to sending change messages to the web editor of a node so
+   that the change note message is actually genrated.
+-  CGI interface wasn't generating correct change messages.
+-  Notes entered during a change are saved to the messages list even if
+   there's no nosy list. No message is generated if there's no nosy list and
+   there's no change note (since it would just duplicates the journal).
+-  Completely removed the bsddb3 module from the tests - will be reinstated
+   when the http://bsddb.sourceforge.net/'s bugs #439959 and #456408 are
+   dealt with. One is fixed in CVS, the other pending.
+
+
+2001-08-08 - 0.2.6
+Note:
+-  Roundup is now released under the same terms as the Python License.
+
+Feature:
+-  Added tests for instance initialisation. No more releasing the software
+   with bugs in roundup.init!
+-  Now bundling unittest with the package so that python 2.0 users can use
+   the tests.
+-  Much better error handling and messages generated by the mail gateway.
+
+Fixed:
+-  Implemented correct mail splitting. Added unit tests. Also snips
+   signatures now too.
+-  Bug #447671 - typo in roundup/init.py
+-  Changed date.Date to use regular string formatting instead of strftime -
+   win32 seems to have problems with %T and no hour... or something...
+-  Bug #448484 - now catching correct exception from makedirs.
+-  Instances are now opened by a special function that generates a unique
+   module name for the instances on import time.
+
+
+2001-08-03 - 0.2.5
+Note:
+-  The bsddb3 module has a bug that renders it non-functional. Users should
+   select the anydbm or bsddb backend instead.
+
+Fixed:
+-  Python 2.0 does not contain the unittest module. The setup.py module now
+   checks for unittest before attempting to run the unit tests.
+
+
+2001-08-03 - 0.2.4
+Features:
+-  Added ability for cgi newblah forms to indicate that the new node
+   should be linked somewhere.
+-  Added time logging and file uploading to the templates.
+-  Added "My Issues" and "My Support" to extended template. Changed "Your
+   Details" to "My Details". Changed the "New Foo" links to "Add Foo".
+   Added links for unassigned support and issues. Generally reorganised and
+   cleanup the header up.
+-  Changed the order of the information in the message generated by web edits.
+-  Extended the range of intervals that are pretty-printed before actual dates
+   are displayed.
+-  Added more BUILD instructions including the "clean" command to force
+   rebuild.
+-  Web edit messages aren't sent to the person who did the edit any more. No
+   message is generated if they are the only person on the nosy list.
+-  Roundupdb now appends "mailing list" information to its messages which
+   include the e-mail address and web interface address. Templates may
+   override this in their db classes to include specific information (support
+   instructions, etc).
+
+Fixed:
+-  Argument handling for the roundup-admin find command.
+-  Handling of summary when no note supplied for newblah. Again.
+-  Detection of no form in htmltemplate Field display.
+-  Checklist html template command was setting wrong name.
+-  2.1-specific gmtime() (no arg) call in roundup.date. (thanks Paul Wright)
+-  mailgw was making naughty assumptions about the schema of the classes it
+   was creating nodes for.
+-  remove the $Foo$ from the HTML files stored in the htmlbase modules.
+-  Instance import now imports the instance using imp.load_module so that
+   we can have instance homes of "roundup" or other existing python package
+   names.
+
+
+2001-07-30 - 0.2.3
+Big change:
+-  I've split off the support class from the issue class in "extended".
+   Anyone who has any support entries, sorry. It should be possible to
+   write a scipt that moves the entries over pretty easily. If this causes
+   you pain, I'll do so. You'll want to update your instance with the new
+   code in "extended" either way.
+
+Features:
+-  Added the unit tests to the start of setup.py so they're run whenever
+   we do anything distutils'y.
+-  Added nicer prompting to the roundup-admin "init" command.
+-  Actually, the roundup-admin code is totally revamped, and has command
+   help and better command-line arg handling.
+-  The cgi_client.Client base class now reflects the structure of "classic"
+   rather than "extended" since "classic" is more of a "base" template.
+-  Added more DB to test. Skips tests where imports fail.
+
+Fixed:
+-  One of the tests in test_date had the wrong expected result.
+-  Fixed IssueClass so that superseders links to its classname rather than
+   hard-coded to "issue".
+-  templatebuilder was catching IOError instead of OSError.
+-  The cgi_client newblah method wasn't detecting the __note form field
+   properly.
+-  The History command in htmltemplate didn't handle a new node (None
+   nodeid) properly.
+
+
+2001-07-29 - 0.2.2
+Features:
+-  Added implementation.txt to the doc directory. Contains implementation
+   notes specific to this implementations of Roundup.
+-  Cleaned up mailgw some (subclass Message for getPart) and added some
+   tests for multipart splitting.
+-  Better checking for html dir in templatebuilder.
+-  Base hyperdb.Class now fakes the "id" property.
+-  Made the classic roundup look more like the original prototype.
+-  Made cgi_client and templating slightly more generic.
+-  Moved some code around in cgi_client allowing for subclassing to change
+   behaviour.
+-  Added the fabricated property "id" to all hyperdb classes.
+-  Cleanup of the link label generation (new method on hyperdb.Class to do
+   it).
+
+Fixed:
+-  Everything uses errno module now to check errno values.
+-  New issue form handles lack of note better now.
+-  HTML templating uses section-bar style for index group headers now.
+-  Fixed problem in link display when Link value is None.
+-  Form handling in cgi client wasn't propogating through the previous
+   query elements.
+-  Fixed sort arguments generated for column headings so sorting can be
+   changed now.
+
+
+2001-07-28 - 0.2.1
+Features:
+-  Added docstring to roundup package so pydoc reports useful information.
+-  Added the roundup 1 software carpentry submission HTML to the doc
+   directory as "overview.html".
+
+Fixes:
+-  Fixed bug in init command - templatebuilder was assuming existence of
+   "html" directory in instance home.
+-  Fixed INSTALL.txt to reflect some changes in the installation and test
+   procedure. Whatdya know, "setup.py install" does the script install.
+   There you go...
+-  Fixed some non-string node ids in cgi_client now that the hyperdb is
+   strict about such things.
+
+2001-07-26 - 0.2.0
+Features:
+-  Major reorganisation of code to allow multiple roundup instances and a
+   single, site-packages -based installation. Also allows multiple database
+   back-ends.
+-  Moved the bin/ proggies into the top dir, so that it all works
+   out-of-the-box
+-  Added the "classic" template - a direct implementation of the Roundup
+   spec. Well, as close as we're going to get, anyway.
+-  Added an issue priority of support to "extended"
+-  Added command-line arg handling to roundup-server so it's more useful 
+   out-of-the-box.
+-  Added distutils-style installation of "lib" files.
+-  Added some unit tests.
+
+Fixes:
+-  Fixed bug in re generation in the filter
+-  Fixed handling of None String property in grouped list headings
+-  Fixed adding new issue with no change note
+-  Fixed values in text input fields which contained quotes (") are now
+   quoted.
+-  Fixed a bug in the hyperdb filter - wrong variable names in the error
+   message.
+
+2001-07-19 - 0.1.3
+-  Reldate now takes an argument "pretty" - when true, it pretty-prints the
+   interval generated up to 5 days, then pretty-prints the date of last
+   activity. The issue index and item now use the pretty format.
+-  Classes list for admin user in CGI interface.
+-  Made the view configuration more accessible, neater and more realistic.
+-  Fixed list view grouping handling grouping by a Multilink or String or Link
+   value of None or Date, ...  (mind you, sorting by Date???)
+-  Fixed bug in the plain formatter when a Link was None.
+-  Fixed ordering of list view column headings.
+-  Fixed list view column heading sort links - and limited the number of
+   columns to sort by to 2.
+-  Added searching by glob to StringType filtering -
+    ^text  - search for text at start of fields
+    text$  - search for text at end of fields
+    ^text$ - exactly match text in fields
+    te*xt  - search for text matching "te"<any characters>"xt"
+    te?xt  - search for text matching "te"<any one character>"xt"
+-  Added more fields to the issue.filter and issue.index templates
+
+
+2001-07-18 - 0.1.2
+-  Set default index to ?:group=priority&:columns=activity,status,title so
+   the priority column isn't displayed.
+-  Thanks Anthony:
+   - added notes to the README about Python prerequisites
+   - added check to roundup.py, roundup.cgi, server.py and roundup-mailgw.py
+     for python 2+ - and made the file itself parseable by 1.5.2 ;)
+   - python 2.0 didn't have the default args for the time module functions.
+   - better handling of db directory in initDB
+-  Sorting on the extra properties defined by roundupdb classes was broken
+   due to the caching used. May now sort on activity and creation
+   properties, etc.
+-  Set the default index to sort on activity
+
+
+2001-07-18 - 0.1.1
+-  Initial version release with consent of Roundup spec author, Ka-Ping Yee:
+   "Amazing!  Nice work.  I'll watch for the source code on your website."
+
+2001-07-11 - 0.1.0
+-  Needed a bug tracking system. Looked around. Tried to install many
+   Perl-based systems, to no avail. Got tired of waiting for Roundup to be
+   released. Had just finished major product project, so needed something
+   different for a while. Roundup here I come...
+
+

Added: tracker/vendor/roundup/current/COPYING.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/COPYING.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,106 @@
+Roundup Licensing
+-----------------
+
+Copyright (c) 2003 Richard Jones (richard at mechanicalcat.net)
+Copyright (c) 2002 eKit.com Inc (http://www.ekit.com/)
+Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+  The above copyright notice and this permission notice shall be included in
+  all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+PageTemplates Licensing
+-----------------------
+
+Portions of this code (roundup.cgi.PageTemplates, roundup.cgi.TAL and
+roundup.cgi.ZTUtils) have been copied from Zope. They have been modified in
+the following manner:
+
+- removal of unit tests, Zope-specific code and support files from 
+  PageTemplates: PageTemplateFile.py, ZPythonExpr.py, ZRPythonExpr.py,
+  ZopePageTemplate.py, examples, help, tests, CHANGES.txt, HISTORY.txt,
+  version.txt and www. From TAL: DummyEngine.py, HISTORY.txt, CHANGES.txt,
+  benchmark, driver.py, markbench.py, ndiff.py, runtest.py, setpath.py,
+  tests and timer.py. From ZTUtils: SimpleTree.py, Zope.py, CHANGES.txt and
+  HISTORY.txt.
+- editing to remove dependencies on Zope modules (see files for change notes)
+
+The license for this code is the `Zope Public License (ZPL) Version 2.0`_,
+included below.
+
+
+Zope Public License (ZPL) Version 2.0
+-------------------------------------
+
+This software is Copyright (c) Zope Corporation (tm) and
+Contributors. All rights reserved.
+
+This license has been certified as open source. It has also
+been designated as GPL compatible by the Free Software
+Foundation (FSF).
+
+Redistribution and use in source and binary forms, with or
+without modification, are permitted provided that the
+following conditions are met:
+
+1. Redistributions in source code must retain the above
+   copyright notice, this list of conditions, and the following
+   disclaimer.
+
+2. Redistributions in binary form must reproduce the above
+   copyright notice, this list of conditions, and the following
+   disclaimer in the documentation and/or other materials
+   provided with the distribution.
+
+3. The name Zope Corporation (tm) must not be used to
+   endorse or promote products derived from this software
+   without prior written permission from Zope Corporation.
+
+4. The right to distribute this software or to use it for
+   any purpose does not give you the right to use Servicemarks
+   (sm) or Trademarks (tm) of Zope Corporation. Use of them is
+   covered in a separate agreement (see
+   http://www.zope.com/Marks).
+
+5. If any files are modified, you must cause the modified
+   files to carry prominent notices stating that you changed
+   the files and the date of any change.
+
+Disclaimer
+
+  THIS SOFTWARE IS PROVIDED BY ZOPE CORPORATION ``AS IS''
+  AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT
+  NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
+  AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.  IN
+  NO EVENT SHALL ZOPE CORPORATION OR ITS CONTRIBUTORS BE
+  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+  EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+  LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+  LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+  HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
+  OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+  SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH
+  DAMAGE.
+
+
+This software consists of contributions made by Zope
+Corporation and many individuals on behalf of Zope
+Corporation.  Specific attributions are listed in the
+accompanying credits file.
+

Added: tracker/vendor/roundup/current/ChangeLog
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/ChangeLog	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,918 @@
+2001-08-03 11:54  richard
+
+	* BUILD.txt, CHANGES.txt, README.txt, setup.py,
+	roundup/templates/classic/htmlbase.py: Started stuff off for the
+	0.2.5 release
+
+2001-08-03 11:28  richard
+
+	* roundup-admin, roundup-mailgw, roundup-server,
+	cgi-bin/roundup.cgi, roundup/init.py: Used the much nicer
+	load_package, pointed out by Steve Majewski.
+
+2001-08-03 11:19  richard
+
+	* roundup/templates/classic/: htmlbase.py, html/issue.item,
+	html/style.css: finished of colourising the classic template
+
+2001-08-03 10:59  richard
+
+	* CHANGES.txt: chnages
+
+2001-08-03 10:59  richard
+
+	* roundup-admin, roundup-mailgw, roundup-server,
+	cgi-bin/roundup.cgi, roundup/init.py: Instance import now imports
+	the instance using imp.load_module so that we can have instance
+	homes of "roundup" or other existing python package names.
+
+2001-08-02 20:26  richard
+
+	* README.txt: changes
+
+2001-08-02 16:38  richard
+
+	* roundup/: cgi_client.py, hyperdb.py, roundupdb.py,
+	templates/classic/dbinit.py, templates/classic/instance_config.py,
+	templates/extended/dbinit.py,
+	templates/extended/instance_config.py: Roundupdb now appends
+	"mailing list" information to its messages which include the e-mail
+	address and web interface address. Templates may override this in
+	their db classes to include specific information (support
+	instructions, etc).
+
+2001-08-02 16:00  richard
+
+	* CHANGES.txt: anges
+
+2001-08-02 15:55  richard
+
+	* roundup/cgi_client.py: Web edit messages aren't sent to the
+	person who did the edit any more. No message is generated if they
+	are the only person on the nosy list.
+
+2001-08-02 11:01  richard
+
+	* CHANGES.txt: changes
+
+2001-08-02 11:00  richard
+
+	* BUILD.txt: Added the 'clean' command to the instructions -
+	distutils doesn't seem to always detect when it needs to rebuild
+	when it should.
+
+2001-08-02 10:43  richard
+
+	* roundup/templates/extended/interfaces.py: Even better (more
+	useful) headings
+
+2001-08-02 10:36  richard
+
+	* roundup/templates/extended/interfaces.py: Made all the
+	user-specific link names the same (My Foo)
+
+2001-08-02 10:34  richard
+
+	* roundup/cgi_client.py: bleah syntax error
+
+2001-08-02 10:27  richard
+
+	* CHANGES.txt, roundup/templates/extended/htmlbase.py: changes
+
+2001-08-02 10:27  richard
+
+	* roundup/date.py: Extended the range of intervals that are
+	pretty-printed before actual dates are displayed.
+
+2001-08-02 10:26  richard
+
+	* roundup/cgi_client.py: Changed the order of the information in
+	the message generated by web edits.
+
+2001-08-01 15:15  richard
+
+	* CHANGES.txt: changes
+
+2001-08-01 15:15  richard
+
+	* README.txt, roundup/templates/extended/htmlbase.py,
+	roundup/templates/extended/interfaces.py,
+	roundup/templates/extended/html/issue.index,
+	roundup/templates/extended/html/support.index: Added "My Issues"
+	and "My Support" to extended template.
+
+2001-08-01 15:06  richard
+
+	* CHANGES.txt: changes
+
+2001-08-01 15:06  richard
+
+	* roundup/: templatebuilder.py, templates/classic/htmlbase.py,
+	templates/extended/htmlbase.py: htmlbase doesn't have extraneous
+	$Foo$ in it any more
+
+2001-08-01 14:24  richard
+
+	* roundup/: hyperdb.py, mailgw.py: mailgw was assuming certain
+	properties existed on the issues being created.
+
+2001-08-01 13:52  richard
+
+	* CHANGES.txt, roundup/htmltemplate.py: Checklist was using wrong
+	name.
+
+2001-08-01 13:48  richard
+
+	* README.txt: Just a new idea...
+
+2001-07-31 19:58  richard
+
+	* CHANGES.txt: changes
+
+2001-07-31 19:54  richard
+
+	* roundup/date.py: Fixed the 2.1-specific gmtime() (no arg) call in
+	roundup.date. (Paul Wright)
+
+2001-07-30 18:12  richard
+
+	* CHANGES.txt, roundup-admin, roundup/cgi_client.py,
+	roundup/htmltemplate.py, roundup/templatebuilder.py,
+	roundup/templates/classic/htmlbase.py,
+	roundup/templates/classic/html/file.newitem,
+	roundup/templates/classic/html/issue.item,
+	roundup/templates/extended/htmlbase.py,
+	roundup/templates/extended/interfaces.py: Added time logging and
+	file uploading to the templates.
+
+2001-07-30 18:04  richard
+
+	* roundup/templates/extended/html/: file.newitem, timelog.index,
+	timelog.item: oops
+
+2001-07-30 18:03  richard
+
+	* roundup/templates/extended/html/: issue.item, support.item: Fixes
+	to the uploading stuff (I forgot to put the code in the issue class
+	;)
+
+2001-07-30 17:17  richard
+
+	* setup.py: Just making sure we've got the right version in there
+	for development.
+
+2001-07-30 16:26  richard
+
+	* roundup/cgi_client.py: Added some documentation on how the
+	newblah works.
+
+2001-07-30 16:17  richard
+
+	* roundup/: cgi_client.py, htmltemplate.py: Features:  . Added
+	ability for cgi newblah forms to indicate that the new node   
+	should be linked somewhere.  Fixed:  . Fixed the agument handling
+	for the roundup-admin find command.   . Fixed handling of summary
+	when no note supplied for newblah. Again.   . Fixed detection of no
+	form in htmltemplate Field display.
+
+2001-07-30 13:53  richard
+
+	* CHANGES.txt: chanegs
+
+2001-07-30 13:52  richard
+
+	* roundup-admin: init help now lists templates and backends
+
+2001-07-30 13:52  richard
+
+	* roundup/backends/__init__.py: Checks for ability to import the
+	specific back-end module.
+
+2001-07-30 13:45  richard
+
+	* test/test_db.py: Added more DB to test_db. Can skip tests where
+	imports fail.
+
+2001-07-30 12:38  richard
+
+	* roundup/templates/: classic/htmlbase.py, extended/htmlbase.py:
+	updated htmlbases
+
+2001-07-30 12:38  richard
+
+	* roundup/: hyperdb.py, roundupdb.py: get() now has a default arg -
+	for migration only.
+
+2001-07-30 12:37  richard
+
+	* roundup/htmltemplate.py: Temporary measure until we have decent
+	schema migration.
+
+2001-07-30 12:37  richard
+
+	* roundup/cgi_client.py: Temporary measure until we have decent
+	schema migration...
+
+2001-07-30 12:37  richard
+
+	* roundup-admin: Freshen is really broken. Commented out.
+
+2001-07-30 12:36  richard
+
+	* roundup/backends/: back_bsddb.py, back_bsddb3.py: Handle
+	non-existence of db files in the other backends (code from anydbm).
+
+2001-07-30 12:35  richard
+
+	* roundup/templates/extended/html/issue.item: Should've been
+	supportcall
+
+2001-07-30 11:47  richard
+
+	* roundup/templates/extended/html/issue.item: Forgot to add the
+	support call property to the item page.
+
+2001-07-30 11:41  richard
+
+	* roundup/backends/: back_anydbm.py, back_bsddb.py, back_bsddb3.py:
+	Makes schema changes mucho easier.
+
+2001-07-30 11:32  richard
+
+	* CHANGES.txt, README.txt: noted changes
+
+2001-07-30 11:28  richard
+
+	* roundup-admin: Bugfixes
+
+2001-07-30 11:28  richard
+
+	* roundup/templates/__init__.py: Support for determining the
+	installed tempaltes
+
+2001-07-30 11:27  richard
+
+	* roundup/templates/extended/html/: support.filter, support.index,
+	support.item: Oops - these are the HTML displays for the support
+	class.
+
+2001-07-30 11:26  richard
+
+	* roundup/templates/extended/: dbinit.py, htmlbase.py,
+	interfaces.py, html/issue.filter, html/issue.index,
+	html/issue.item: Big changes:  . split off the support priority
+	into its own class  . added "new support, new user" to the page
+	head  . fixed the display options for the heading links
+
+2001-07-30 11:25  richard
+
+	* roundup/templates/classic/: htmlbase.py, interfaces.py: Changes
+	to reflect cgi_client now implementing this template by default,
+	and not "extended".
+
+2001-07-30 11:25  richard
+
+	* roundup/cgi_client.py: Default implementation is now "classic"
+	rather than "extended" as one would expect.
+
+2001-07-30 11:24  richard
+
+	* roundup/htmltemplate.py: Handles new node display now.
+
+2001-07-30 10:57  richard
+
+	* roundup-admin: Now uses getopt, much improved command-line
+	parsing. Much fuller help. Much better internal structure. It's
+	just BETTER. :)
+
+2001-07-30 10:06  richard
+
+	* roundup/templatebuilder.py: Hrm - had IOError instead of OSError.
+	Not sure why there's two. Ho hum.
+
+2001-07-30 10:05  richard
+
+	* roundup/roundupdb.py: Fixed IssueClass so that superseders links
+	to its classname rather than hard-coded to "issue".
+
+2001-07-30 10:04  richard
+
+	* roundup-admin: Made the "init" prompting more friendly.
+
+2001-07-30 09:34  richard
+
+	* CHANGES.txt, roundup/templates/classic/htmlbase.py,
+	roundup/templates/extended/htmlbase.py: changes
+
+2001-07-30 09:34  richard
+
+	* setup.py: Added unit tests so they're run whenever we
+	package/install/whatever.
+
+2001-07-30 09:32  richard
+
+	* test/test_dates.py: Fixed bug in unit test ;)
+
+2001-07-29 19:43  richard
+
+	* setup.py: Make sure that the htmlbase is up-to-date when we build
+	a source dist.
+
+2001-07-29 19:33  richard
+
+	* CHANGES.txt: changes
+
+2001-07-29 19:31  richard
+
+	* roundup/htmltemplate.py: oops
+
+2001-07-29 19:28  richard
+
+	* roundup/: htmltemplate.py, hyperdb.py: Fixed sorting by clicking
+	on column headings.
+
+2001-07-29 18:37  richard
+
+	* CHANGES.txt, README.txt, setup.py: changes
+
+2001-07-29 18:27  richard
+
+	* roundup/: cgi_client.py, htmltemplate.py, hyperdb.py: Fixed
+	handling of passed-in values in form elements (ie. during a
+	drill-down)
+
+2001-07-29 17:01  richard
+
+	* README.txt, roundup-admin, roundup-mailgw, roundup-server,
+	setup.py, cgi-bin/roundup.cgi, roundup/__init__.py,
+	roundup/cgi_client.py, roundup/cgitb.py, roundup/date.py,
+	roundup/htmltemplate.py, roundup/hyperdb.py, roundup/init.py,
+	roundup/mailgw.py, roundup/roundupdb.py,
+	roundup/templatebuilder.py, roundup/templates/classic/__init__.py,
+	roundup/templates/classic/dbinit.py,
+	roundup/templates/classic/instance_config.py,
+	roundup/templates/classic/interfaces.py,
+	roundup/templates/extended/__init__.py,
+	roundup/templates/extended/dbinit.py,
+	roundup/templates/extended/instance_config.py,
+	roundup/templates/extended/interfaces.py, test/README.txt,
+	test/__init__.py, test/test_dates.py, test/test_db.py,
+	test/test_multipart.py, test/test_schema.py: Added vim command to
+	all source so that we don't get no steenkin' tabs :)
+
+2001-07-29 16:42  richard
+
+	* test/test_dates.py: Added Interval tests.
+
+2001-07-29 15:41  richard
+
+	* CHANGES.txt: changes
+
+2001-07-29 15:36  richard
+
+	* roundup/: htmltemplate.py, hyperdb.py: Cleanup of the link label
+	generation.
+
+2001-07-29 14:11  richard
+
+	* CHANGES.txt: Reverse the entries so most recent is first.
+
+2001-07-29 14:09  richard
+
+	* test/test_db.py: Added the fabricated property "id" to all
+	hyperdb classes.
+
+2001-07-29 14:07  richard
+
+	* roundup/templates/classic/: interfaces.py, html/file.index,
+	html/issue.filter, html/issue.index, html/issue.item,
+	html/msg.index, html/msg.item, html/style.css, html/user.index,
+	html/user.item: Fixed the classic template so it's more like the
+	"advertised" Roundup template.
+
+2001-07-29 14:06  richard
+
+	* roundup/htmltemplate.py: Fixed problem in link display when Link
+	value is None.
+
+2001-07-29 14:05  richard
+
+	* roundup/: hyperdb.py, roundupdb.py: Added the fabricated property
+	"id".
+
+2001-07-29 14:04  richard
+
+	* roundup/cgi_client.py: Moved some code around allowing for
+	subclassing to change behaviour.
+
+2001-07-28 18:17  richard
+
+	* roundup/htmltemplate.py: fixed use of stylesheet
+
+2001-07-28 18:16  richard
+
+	* roundup/cgi_client.py: New issue form handles lack of note better
+	now.
+
+2001-07-28 18:02  richard
+
+	* roundup/templatebuilder.py: commented out print
+
+2001-07-28 17:59  richard
+
+	* roundup/: htmltemplate.py, init.py, templatebuilder.py: Replaced
+	errno integers with their module values.  De-tabbed
+	templatebuilder.py
+
+2001-07-28 17:35  richard
+
+	* README.txt: todo refinement ;)
+
+2001-07-28 16:44  richard
+
+	* CHANGES.txt, README.txt, doc/implementation.txt: Split off
+	implementation notes into separate file in doc directory. Added
+	some todo items to the README
+
+2001-07-28 16:43  richard
+
+	* roundup/mailgw.py, test/__init__.py, test/test_multipart.py:
+	Multipart message class has the getPart method now. Added some
+	tests for it.
+
+2001-07-28 11:56  richard
+
+	* CHANGES.txt, MANIFEST.in: changes
+
+2001-07-28 11:45  richard
+
+	* doc/: overview.html, spec.html, images/edit.gif, images/edit.png,
+	images/hyperdb.gif, images/hyperdb.png, images/logo-acl-medium.gif,
+	images/logo-acl-medium.png, images/logo-codesourcery-medium.gif,
+	images/logo-codesourcery-medium.png,
+	images/logo-software-carpentry-standard.gif,
+	images/logo-software-carpentry-standard.png, images/roundup-1.gif,
+	images/roundup-1.png, images/roundup.gif, images/roundup.png: GIF
+	-> PNG, saving about 100k
+
+2001-07-28 11:40  richard
+
+	* doc/: overview.html, images/edit.gif, images/hyperdb.gif,
+	images/roundup-1.gif, images/roundup.gif: added more documentation
+
+2001-07-28 11:39  richard
+
+	* roundup/__init__.py: Added some documentation to the roundup
+	package.
+
+2001-07-28 10:39  richard
+
+	* CHANGES.txt, setup.py: changes for the 0.2.1 distribution build.
+
+2001-07-28 10:34  richard
+
+	* CHANGES.txt: changes
+
+2001-07-28 10:34  richard
+
+	* roundup/: cgi_client.py, mailgw.py: Fixed some non-string node
+	ids.
+
+2001-07-28 10:31  richard
+
+	* INSTALL.txt, roundup/templatebuilder.py: Fixed some problems with
+	installation.
+
+2001-07-27 17:33  richard
+
+	* INSTALL.txt: more notes for installation
+
+2001-07-27 17:30  richard
+
+	* BUILD.txt: minor notes
+
+2001-07-27 17:27  richard
+
+	* BUILD.txt, README.txt: Added build instructions, changed my
+	e-mail address in the docs to the sourceforge address.
+
+2001-07-27 17:20  richard
+
+	* Makefile, setup.cfg, setup.py: Makefile is now obsolete - setup
+	does what it used to do.
+
+2001-07-27 17:18  richard
+
+	* MANIFEST.in: Added the distutils manifest template (for
+	"documentation", see distutils.filelist).  Has no facility for
+	comments, so no ID or LOG for this baby.
+
+2001-07-27 17:16  richard
+
+	* test/: README.TXT, README.txt: rename for consistency
+
+2001-07-27 17:04  richard
+
+	* INSTALL.TXT, CHANGES.txt, INSTALL.txt, README.TXT, README.txt:
+	name changes to make distutils happy
+
+2001-07-27 16:56  richard
+
+	* setup.cfg, setup.py: Added scripts to the setup and added the
+	config so the default script install dir is /usr/local/bin.
+
+2001-07-27 16:55  richard
+
+	* test/: README.TXT, __init__.py, test_dates.py, test_db.py,
+	test_schema.py: moving tests -> test
+
+2001-07-27 16:25  richard
+
+	* roundup/hyperdb.py: Fixed some of the exceptions so they're the
+	right type.  Removed the str()-ification of node ids so we don't
+	mask oopsy errors any more.
+
+2001-07-27 15:17  richard
+
+	* roundup/hyperdb.py: just some comments
+
+2001-07-26 17:14  richard
+
+	* setup.py: Made setup.py executable, added id and log.
+
+2001-07-26 16:47  richard
+
+	* INSTALL.TXT: Updated for new installation procedure
+
+2001-07-25 14:19  anthonybaxter
+
+	* setup.py: first cut at setup.py - installs the package, but not
+	the bin/cgi-bin yet
+
+2001-07-25 14:09  richard
+
+	* roundup/date.py: Fixed offset handling (shoulda read the spec a
+	little better)
+
+2001-07-25 13:40  richard
+
+	* README.TXT: added note about the spec
+
+2001-07-25 13:39  richard
+
+	* roundup/htmltemplate.py: Hrm - displaying links to classes that
+	don't specify a key property. I've got it defaulting to 'name',
+	then 'title' and then a "random" property (first one returned by
+	getprops().keys().  Needs to be moved onto the Class I think...
+
+2001-07-25 11:23  richard
+
+	* doc/spec.html, doc/images/logo-acl-medium.gif,
+	doc/images/logo-codesourcery-medium.gif,
+	doc/images/logo-software-carpentry-standard.gif,
+	roundup/backends/back_anydbm.py,
+	roundup/templates/extended/dbinit.py: Added the Roundup spec to the
+	new documentation directory.
+
+2001-07-24 21:18  anthonybaxter
+
+	* roundup/init.py: oops. left a print in
+
+2001-07-24 20:54  anthonybaxter
+
+	* roundup/: init.py, templatebuilder.py: oops. Html.
+
+2001-07-24 20:46  anthonybaxter
+
+	* roundup/: init.py, templatebuilder.py, templates/__init__.py,
+	templates/classic/__init__.py, templates/classic/dbinit.py,
+	templates/classic/htmlbase.py, templates/extended/__init__.py,
+	templates/extended/htmlbase.py: Added templatebuilder module. two
+	functions - one to pack up the html base, one to unpack it. Packed
+	up the two standard templates into htmlbases.  Modified __init__ to
+	install them.
+	
+	__init__.py magic was needed for the rather high levels of wierd
+	import magic.  Reducing level of import magic == (good, future)
+
+2001-07-24 14:26  anthonybaxter
+
+	* roundup/backends/back_bsddb3.py: bsddb3 implementation. For now,
+	it's the bsddb implementation with a "3" added in crayon.
+
+2001-07-24 11:07  richard
+
+	* roundup-server: Added command-line arg handling to roundup-server
+	so it's more useful out-of-the-box.
+
+2001-07-24 11:06  richard
+
+	* roundup/templates/classic/dbinit.py: Oops - accidentally duped
+	the keywords class
+
+2001-07-24 09:32  richard
+
+	* INSTALL.TXT: minor edit
+
+2001-07-24 09:28  richard
+
+	* roundup/templates/: README.txt, classic/__init__.py,
+	classic/dbinit.py, classic/instance_config.py,
+	classic/interfaces.py, classic/detectors/__init__.py,
+	classic/detectors/nosyreaction.py, classic/html/file.index,
+	classic/html/issue.filter, classic/html/issue.index,
+	classic/html/issue.item, classic/html/msg.index,
+	classic/html/msg.item, classic/html/style.css,
+	classic/html/user.index, classic/html/user.item: Adding the classic
+	template
+
+2001-07-24 09:20  richard
+
+	* roundup/templates/extended/dbinit.py: forgot to remove the
+	interfaces from the dbinit module ;)
+
+2001-07-24 09:16  richard
+
+	* roundup/templates/extended/: __init__.py, interfaces.py: Split
+	off the interfaces (CGI, mailgw) into a separate file from the DB
+	stuff.
+
+2001-07-23 20:31  richard
+
+	* roundup-server: disabled the reloading until it can be done
+	properly
+
+2001-07-23 18:55  richard
+
+	* CHANGES, INSTALL.TXT, README, README.TXT: renamed the text files
+	so that they're recognised as text files on windows added
+	INSTALL.TXT
+
+2001-07-23 18:53  richard
+
+	* README, roundup-server: Fixed the ROUNDUPS decl in roundup-server
+	Move the installation notes to INSTALL
+
+2001-07-23 18:45  richard
+
+	* roundup-admin, roundup/init.py,
+	roundup/templates/extended/dbinit.py: ok, so now "./roundup-admin
+	init" will ask questions in an attempt to get a workable
+	instance_home set up :) _and_ anydbm has had its first test :)
+
+2001-07-23 18:25  richard
+
+	* roundup/backends/back_bsddb.py: more handling of bad journals
+
+2001-07-23 18:20  richard
+
+	* roundup-admin, roundup/backends/back_anydbm.py,
+	roundup/backends/back_bsddb.py: Moved over to using marshal in the
+	bsddb and anydbm backends.  roundup-admin now has a "freshen"
+	command that'll load/save all nodes (not  retired - mod
+	hyperdb.Class.list() so it lists retired nodes)
+
+2001-07-23 17:56  richard
+
+	* roundup/: date.py, backends/back_bsddb.py: Storing only
+	marshallable data in the db - no nasty pickled class references.
+
+2001-07-23 17:22  richard
+
+	* roundup/backends/: __init__.py, _anydbm.py, _bsddb.py,
+	back_anydbm.py, back_bsddb.py: *sigh* some databases have _foo.so
+	as their underlying implementation.  This time for sure, Rocky.
+
+2001-07-23 17:15  richard
+
+	* roundup/backends/: _anydbm.py, _bsddb.py, bsddb.py: Moved the
+	backends into the backends package. Anydbm hasn't been tested at
+	all.
+
+2001-07-23 17:14  richard
+
+	* roundup/: roundupdb.py, backends/__init__.py,
+	templates/extended/dbinit.py: Moved the database backends off into
+	backends.
+
+2001-07-23 16:25  richard
+
+	* roundup/templates/extended/dbinit.py: relfected the move to
+	roundup/backends
+
+2001-07-23 16:24  richard
+
+	* roundup/backends/__init__.py: made backends a package
+
+2001-07-23 16:23  richard
+
+	* roundup/: hyper_bsddb.py, backends/bsddb.py: moved hyper_bsddb.py
+	to the new backends package as bsddb.py
+
+2001-07-23 14:49  anthonybaxter
+
+	* README: changed the 'snip' lines so they don't look like CVS
+	conflict markers.
+
+2001-07-23 14:47  anthonybaxter
+
+	* cgi-bin/roundup.cgi: renamed ROUNDUPS to ROUNDUP_INSTANCE_HOMES
+	sys.exit(0) if python version wrong.
+
+2001-07-23 14:33  richard
+
+	* cgi-bin/roundup.cgi: brought the CGI instance config dict in line
+	with roundup-server
+
+2001-07-23 14:33  anthonybaxter
+
+	* roundup/templates/extended/: __init__.py, dbinit.py,
+	instance_config.py: split __init__.py into 2. dbinit and
+	instance_config.
+
+2001-07-23 14:31  richard
+
+	* CHANGES, cgi-bin/roundup.cgi: Fixed the roundup CGI script for
+	updates to cgi_client.py
+
+2001-07-23 14:21  richard
+
+	* roundup/templates/extended/: html/file.index, html/issue.filter,
+	html/issue.index, html/issue.item, html/msg.index, html/msg.item,
+	html/style.css, html/user.index, html/user.item, issue.filter,
+	issue.item, msg.item, style.css, user.item: moving HTML templates
+	to their own dir
+
+2001-07-23 14:19  richard
+
+	* roundup/templates/extended/: file.index, issue.index, msg.index,
+	user.index: moving the HTML templates into their own dir
+
+2001-07-23 14:05  anthonybaxter
+
+	* roundup-server: actually quit if python version wrong
+
+2001-07-23 13:56  richard
+
+	* roundup/cgi_client.py: oops, missed a config removal
+
+2001-07-23 13:50  anthonybaxter
+
+	* roundup/templates/extended/: __init__.py, file.index,
+	issue.filter, issue.index, issue.item, msg.index, msg.item,
+	style.css, user.index, user.item, detectors/__init__.py,
+	detectors/nosyreaction.py: moved templates to proper location
+
+2001-07-23 13:46  richard
+
+	* roundup-admin, roundup-mailgw, roundup-server: moving the bin
+	files to facilitate out-of-the-boxness
+
+2001-07-22 22:09  richard
+
+	* roundup/: __init__.py, cgi_client.py, cgitb.py, date.py,
+	htmltemplate.py, hyper_bsddb.py, hyperdb.py, init.py, mailgw.py,
+	roundupdb.py: Final commit of Grande Splite
+
+2001-07-22 21:58  richard
+
+	* roundup/: __init__.py, cgi_client.py, cgitb.py, date.py,
+	htmltemplate.py, hyper_bsddb.py, hyperdb.py, init.py, mailgw.py,
+	roundupdb.py: More Grande Splite
+
+2001-07-22 21:47  richard
+
+	* cgi-bin/roundup.cgi: More Grande Splite
+
+2001-07-22 21:11  richard
+
+	* CHANGES, README, cgitb.py, config.py, date.py, hyperdb.py,
+	hyperdb_bsddb.py, roundup-mailgw.py, roundup.cgi, roundup.py,
+	roundup_cgi.py, roundupdb.py, server.py, style.css, template.py,
+	test.py: Initial commit of the Grande Splite
+
+2001-07-20 22:33  richard
+
+	* server.py: oops ;)
+
+2001-07-20 18:20  richard
+
+	* CHANGES: update for recent chagnes
+
+2001-07-20 18:20  richard
+
+	* README, hyperdb.py: Fixed a bug in the filter - wrong variable
+	names in the error message.  Recognised that the filter has an
+	outstanding bug. Hrm. we need a bug tracker for this project :)
+
+2001-07-20 17:35  richard
+
+	* CHANGES, hyperdb.py, hyperdb_bsddb.py, roundup_cgi.py,
+	roundupdb.py, test.py: largish changes as a start of splitting off
+	bits and pieces to allow more flexible installation / database
+	back-ends
+
+2001-07-20 17:34  richard
+
+	* template.py: Quote the value put in the text input value
+	attribute.
+
+2001-07-20 11:37  richard
+
+	* README: Just registering a new TODO
+
+2001-07-20 10:53  richard
+
+	* roundup_cgi.py: Default index now filters out the resolved issues
+	;)
+
+2001-07-20 10:23  richard
+
+	* CHANGES: update for latest changes
+
+2001-07-20 10:22  richard
+
+	* roundupdb.py: Priority list changes - removed the redundant TODO
+	and added support. See roundup-devel for details.
+
+2001-07-20 10:17  richard
+
+	* roundup_cgi.py: Fixed adding a new issue when there is no __note
+
+2001-07-19 20:43  anthonybaxter
+
+	* config.py, server.py: HTTP_HOST and HTTP_PORT config options.
+
+2001-07-19 16:37  anthonybaxter
+
+	* README: added more todo items
+
+2001-07-19 16:27  anthonybaxter
+
+	* cgitb.py, config.py, date.py, hyperdb.py, roundup-mailgw.py,
+	roundup.py, roundup_cgi.py, roundupdb.py, server.py, template.py:
+	fixing (manually) the (dollarsign)Log(dollarsign) entries caused by
+	my using the magic (dollarsign)Id(dollarsign) and
+	(dollarsign)Log(dollarsign) strings in a commit message. I'm a
+	twonk.
+	
+	Also broke the help string in two.
+
+2001-07-19 16:14  richard
+
+	* Makefile, README, dummy_config.py: minor changes to test the cvs
+	mailout system
+
+2001-07-19 16:08  anthonybaxter
+
+	* roundup.py: fixed typo in usage string because it was bugging me
+	each time I saw it.
+
+2001-07-19 15:52  anthonybaxter
+
+	* cgitb.py, config.py, date.py, hyperdb.py, roundup-mailgw.py,
+	roundup.py, roundup_cgi.py, roundupdb.py, server.py, template.py:
+	Added CVS keywords $Id: ChangeLog,v 1.7 2001/08/03 02:12:07 anthonybaxter Exp $ and $Log: ChangeLog,v $
+	Added CVS keywords $Id$ and Revision 1.7  2001/08/03 02:12:07  anthonybaxter
+	Added CVS keywords $Id$ and regenerated on Fri Aug  3 12:12:00 EST 2001
+	Added CVS keywords $Id$ and to all python files.
+
+2001-07-19 15:46  anthonybaxter
+
+	* config.py: modified to use localconfig.py (if it exists) and to
+	make the various options (e.g. paths) based on ROUNDUP_HOME &c.
+
+2001-07-19 15:23  richard
+
+	* CHANGES, Makefile, config.py, hyperdb.py, roundup_cgi.py,
+	roundupdb.py, template.py:  . Fixed bug in re generation in the
+	filter (I hadn't finished the code ;)
+	 . Added TODO as a priority (between bug and usability)
+	 . Fixed handling of None String property in grouped list headings
+
+2001-07-19 13:12  richard
+
+	* README: mention config.py in the install instructions, removed a
+	bug
+
+2001-07-19 13:11  richard
+
+	* Makefile, dummy_config.py: Added stuff to help with release
+	generation.   . Makefile has the release tgz builder in it   .
+	dummy_config.py is an empty config file that replaces the config.py
+	in the	   release
+
+2001-07-19 12:16  richard
+
+	* README, date.py, hyperdb.py, roundup.cgi, roundup_cgi.py,
+	roundupdb.py, CHANGES, cgitb.py, config.py, roundup-mailgw.py,
+	roundup.py, server.py, style.css, template.py: Initial revision
+
+2001-07-19 12:16  richard
+
+	* README, date.py, hyperdb.py, roundup.cgi, roundup_cgi.py,
+	roundupdb.py, CHANGES, cgitb.py, config.py, roundup-mailgw.py,
+	roundup.py, server.py, style.css, template.py: Initial import of
+	code - currently version 1.0.2 but with the 1.0.3 changes as given
+	in the CHANGES file. Is about ready for a 1.0.3 release.
+

Added: tracker/vendor/roundup/current/MANIFEST.in
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/MANIFEST.in	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,14 @@
+recursive-include roundup *.*
+recursive-include frontends *.*
+recursive-include scripts *.* *-*
+recursive-include tools *.*
+recursive-include cgi-bin *.cgi
+recursive-include test *.py *.txt
+recursive-include doc *.html *.png *.txt *.css *.1
+recursive-include detectors *.py
+recursive-include templates *.* home* page*
+global-exclude .cvsignore *.pyc *.pyo .DS_Store
+include run_tests.py *.txt demo.py MANIFEST.in MANIFEST
+exclude BUILD.txt I18N_PROGRESS.txt TODO.txt
+exclude doc/security.txt doc/templating.txt
+include locale/*.po locale/*.mo locale/roundup.pot

Added: tracker/vendor/roundup/current/README.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/README.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,39 @@
+=======================================================
+Roundup: an Issue-Tracking System for Knowledge Workers
+=======================================================
+
+Copyright (c) 2003 Richard Jones (richard at mechanicalcat.net)
+Copyright (c) 2002 eKit.com Inc (http://www.ekit.com/)
+Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+
+
+INSTANT GRATIFICATION
+=====================
+
+The impatient may try Roundup immediately by typing at the console::
+
+   python demo.py
+
+To start anew (a fresh demo instance)::
+
+   python demo.py nuke
+
+Installation
+============
+For installation instructions, please see installation.txt in the "doc"
+directory.
+
+
+Upgrading
+=========
+For upgrading instructions, please see upgrading.txt in the "doc" directory.
+
+
+Usage and Other Information
+===========================
+See the index.txt file in the "doc" directory.
+
+
+License
+=======
+See COPYING.txt

Added: tracker/vendor/roundup/current/cgi-bin/roundup.cgi
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/cgi-bin/roundup.cgi	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,229 @@
+#!/usr/bin/env python
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+# 
+# $Id: roundup.cgi,v 1.42 2005/05/18 05:39:21 richard Exp $
+
+# python version check
+from roundup import version_check
+from roundup.i18n import _
+import sys, time
+
+#
+##  Configuration
+#
+
+# Configuration can also be provided through the OS environment (or via
+# the Apache "SetEnv" configuration directive). If the variables
+# documented below are set, they _override_ any configuation defaults
+# given in this file. 
+
+# TRACKER_HOMES is a list of trackers, in the form
+# "NAME=DIR<sep>NAME2=DIR2<sep>...", where <sep> is the directory path
+# separator (";" on Windows, ":" on Unix). 
+
+# Make sure the NAME part doesn't include any url-unsafe characters like 
+# spaces, as these confuse the cookie handling in browsers like IE.
+
+# ROUNDUP_LOG is the name of the logfile; if it's empty or does not exist,
+# logging is turned off (unless you changed the default below). 
+
+# DEBUG_TO_CLIENT specifies whether debugging goes to the HTTP server (via
+# stderr) or to the web client (via cgitb).
+DEBUG_TO_CLIENT = False
+
+# This indicates where the Roundup tracker lives
+TRACKER_HOMES = {
+#    'example': '/path/to/example',
+}
+
+# Where to log debugging information to. Use an instance of DevNull if you
+# don't want to log anywhere.
+class DevNull:
+    def write(self, info):
+        pass
+    def close(self):
+        pass
+    def flush(self):
+        pass
+#LOG = open('/var/log/roundup.cgi.log', 'a')
+LOG = DevNull()
+
+#
+##  end configuration
+#
+
+
+#
+# Set up the error handler
+# 
+try:
+    import traceback, StringIO, cgi
+    from roundup.cgi import cgitb
+except:
+    print "Content-Type: text/plain\n"
+    print _("Failed to import cgitb!\n\n")
+    s = StringIO.StringIO()
+    traceback.print_exc(None, s)
+    print s.getvalue()
+
+
+#
+# Check environment for config items
+#
+def checkconfig():
+    import os, string
+    global TRACKER_HOMES, LOG
+
+    # see if there's an environment var. ROUNDUP_INSTANCE_HOMES is the
+    # old name for it.
+    if os.environ.has_key('ROUNDUP_INSTANCE_HOMES'):
+        homes = os.environ.get('ROUNDUP_INSTANCE_HOMES')
+    else:
+        homes = os.environ.get('TRACKER_HOMES', '')
+    if homes:
+        TRACKER_HOMES = {}
+        for home in string.split(homes, os.pathsep):
+            try:
+                name, dir = string.split(home, '=', 1)
+            except ValueError:
+                # ignore invalid definitions
+                continue
+            if name and dir:
+                TRACKER_HOMES[name] = dir
+                
+    logname = os.environ.get('ROUNDUP_LOG', '')
+    if logname:
+        LOG = open(logname, 'a')
+
+    # ROUNDUP_DEBUG is checked directly in "roundup.cgi.client"
+
+
+#
+# Provide interface to CGI HTTP response handling
+#
+class RequestWrapper:
+    '''Used to make the CGI server look like a BaseHTTPRequestHandler
+    '''
+    def __init__(self, wfile):
+        self.wfile = wfile
+    def write(self, data):
+        self.wfile.write(data)
+    def send_response(self, code):
+        self.write('Status: %s\r\n'%code)
+    def send_header(self, keyword, value):
+        self.write("%s: %s\r\n" % (keyword, value))
+    def end_headers(self):
+        self.write("\r\n")
+
+#
+# Main CGI handler
+#
+def main(out, err):
+    import os, string
+    import roundup.instance
+    path = string.split(os.environ.get('PATH_INFO', '/'), '/')
+    request = RequestWrapper(out)
+    request.path = os.environ.get('PATH_INFO', '/')
+    tracker = path[1]
+    os.environ['TRACKER_NAME'] = tracker
+    os.environ['PATH_INFO'] = string.join(path[2:], '/')
+    if TRACKER_HOMES.has_key(tracker):
+        # redirect if we need a trailing '/'
+        if len(path) == 2:
+            request.send_response(301)
+            # redirect
+            if os.environ.get('HTTPS', '') == 'on':
+                protocol = 'https'
+            else:
+                protocol = 'http'
+            absolute_url = '%s://%s%s/'%(protocol, os.environ['HTTP_HOST'],
+                os.environ.get('REQUEST_URI', ''))
+            request.send_header('Location', absolute_url)
+            request.end_headers()
+            out.write('Moved Permanently')
+        else:
+            tracker_home = TRACKER_HOMES[tracker]
+            tracker = roundup.instance.open(tracker_home)
+            import roundup.cgi.client
+            if hasattr(tracker, 'Client'):
+                client = tracker.Client(tracker, request, os.environ)
+            else:
+                client = roundup.cgi.client.Client(tracker, request, os.environ)
+            try:
+                client.main()
+            except roundup.cgi.client.Unauthorised:
+                request.send_response(403)
+                request.send_header('Content-Type', 'text/html')
+                request.end_headers()
+                out.write('Unauthorised')
+            except roundup.cgi.client.NotFound:
+                request.send_response(404)
+                request.send_header('Content-Type', 'text/html')
+                request.end_headers()
+                out.write('Not found: %s'%client.path)
+
+    else:
+        import urllib
+        request.send_response(200)
+        request.send_header('Content-Type', 'text/html')
+        request.end_headers()
+        w = request.write
+        w(_('<html><head><title>Roundup trackers index</title></head>\n'))
+        w(_('<body><h1>Roundup trackers index</h1><ol>\n'))
+        homes = TRACKER_HOMES.keys()
+        homes.sort()
+        for tracker in homes:
+            w(_('<li><a href="%(tracker_url)s/index">%(tracker_name)s</a>\n')%{
+                'tracker_url': os.environ['SCRIPT_NAME']+'/'+
+                               urllib.quote(tracker),
+                'tracker_name': cgi.escape(tracker)})
+        w(_('</ol></body></html>'))
+
+#
+# Now do the actual CGI handling
+#
+out, err = sys.stdout, sys.stderr
+try:
+    # force input/output to binary (important for file up/downloads)
+    if sys.platform == "win32":
+        import os, msvcrt
+        msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY)
+        msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY)
+    checkconfig()
+    sys.stdout = sys.stderr = LOG
+    main(out, err)
+except SystemExit:
+    pass
+except:
+    sys.stdout, sys.stderr = out, err
+    out.write('Content-Type: text/html\n\n')
+    if DEBUG_TO_CLIENT:
+        cgitb.handler()
+    else:
+        out.write(cgitb.breaker())
+        ts = time.ctime()
+        out.write('''<p>%s: An error occurred. Please check
+            the server log for more infomation.</p>'''%ts)
+        print >> sys.stderr, 'EXCEPTION AT', ts
+        traceback.print_exc(0, sys.stderr)
+
+sys.stdout.flush()
+sys.stdout, sys.stderr = out, err
+LOG.close()
+
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/demo.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/demo.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,129 @@
+#! /usr/bin/env python
+#
+# Copyright (c) 2003 Richard Jones (richard at mechanicalcat.net)
+#
+# $Id: demo.py,v 1.24 2006/02/08 04:03:54 richard Exp $
+
+import errno
+import os
+import socket
+import sys
+import urlparse
+from glob import glob
+
+from roundup import configuration
+from roundup.scripts import roundup_server
+
+def install_demo(home, backend, template):
+    """Install a demo tracker
+
+    Parameters:
+        home:
+            tracker home directory path
+        backend:
+            database backend name
+        template:
+            full path to the tracker template directory
+
+    """
+    from roundup import init, instance, password, backends
+
+    # set up the config for this tracker
+    config = configuration.CoreConfig()
+    config['TRACKER_HOME'] = home
+    config['MAIL_DOMAIN'] = 'localhost'
+    config['DATABASE'] = 'db'
+    config['WEB_DEBUG'] = True
+    if backend in ('mysql', 'postgresql'):
+        config['RDBMS_HOST'] = 'localhost'
+        config['RDBMS_USER'] = 'rounduptest'
+        config['RDBMS_PASSWORD'] = 'rounduptest'
+        config['RDBMS_NAME'] = 'rounduptest'
+
+    # see if we have further db nuking to perform
+    module = backends.get_backend(backend)
+    if module.db_exists(config):
+        module.db_nuke(config)
+
+    init.install(home, template)
+    # don't have email flying around
+    os.remove(os.path.join(home, 'detectors', 'nosyreaction.py'))
+    try:
+        os.remove(os.path.join(home, 'detectors', 'nosyreaction.pyc'))
+    except os.error, error:
+        if error.errno != errno.ENOENT:
+            raise
+    init.write_select_db(home, backend)
+
+    # figure basic params for server
+    hostname = 'localhost'
+    # pick a fairly odd, random port
+    port = 8917
+    while 1:
+        print 'Trying to set up web server on port %d ...'%port,
+        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+        try:
+            s.connect((hostname, port))
+        except socket.error, e:
+            if not hasattr(e, 'args') or e.args[0] != errno.ECONNREFUSED:
+                raise
+            print 'should be ok.'
+            break
+        else:
+            s.close()
+            print 'already in use.'
+            port += 100
+    config['TRACKER_WEB'] = 'http://%s:%s/demo/'%(hostname, port)
+
+    # write the config
+    config['INSTANT_REGISTRATION'] = 1
+    config.save(os.path.join(home, config.INI_FILE))
+
+    # open the tracker and initialise
+    tracker = instance.open(home)
+    tracker.init(password.Password('admin'))
+
+    # add the "demo" user
+    db = tracker.open('admin')
+    db.user.create(username='demo', password=password.Password('demo'),
+        realname='Demo User', roles='User')
+    db.commit()
+    db.close()
+
+def run_demo(home):
+    """Run the demo tracker installed in ``home``"""
+    cfg = configuration.CoreConfig(home)
+    url = cfg["TRACKER_WEB"]
+    hostname, port = urlparse.urlparse(url)[1].split(':')
+    port = int(port)
+    success_message = '''Server running - connect to:
+    %s
+1. Log in as "demo"/"demo" or "admin"/"admin".
+2. Hit Control-C to stop the server.
+3. Re-start the server by running "python demo.py" again.
+4. Re-initialise the server by running "python demo.py nuke".
+''' % url
+
+    # disable command line processing in roundup_server
+    sys.argv = sys.argv[:1] + ['-p', str(port), 'demo=' + home]
+    roundup_server.run(success_message=success_message)
+
+def demo_main():
+    """Run a demo server for users to play with for instant gratification.
+
+    Sets up the web service on localhost. Disables nosy lists.
+    """
+    home = os.path.abspath('demo')
+    if not os.path.exists(home) or (sys.argv[-1] == 'nuke'):
+        if len(sys.argv) > 2:
+            backend = sys.argv[-2]
+        else:
+            backend = 'anydbm'
+        install_demo(home, backend, os.path.join('templates', 'classic'))
+    run_demo(home)
+
+if __name__ == '__main__':
+    demo_main()
+
+# vim: set filetype=python sts=4 sw=4 et si :

Added: tracker/vendor/roundup/current/detectors/creator_resolution.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/detectors/creator_resolution.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,43 @@
+# This detector was written by richard at mechanicalcat.net and it's been
+# placed in the Public Domain. Copy and modify to your heart's content.
+
+#$Id: creator_resolution.py,v 1.2 2004/04/07 06:32:54 richard Exp $
+
+from roundup.exceptions import Reject
+
+def creator_resolution(db, cl, nodeid, newvalues):
+    '''Catch attempts to set the status to "resolved" - if the assignedto
+    user isn't the creator, then set the status to "in-progress" (try
+    "confirm-done" first though, but "classic" Roundup doesn't have that
+    status)
+    '''
+    if not newvalues.has_key('status'):
+        return
+
+    # get the resolved state ID
+    resolved_id = db.status.lookup('resolved')
+
+    if newvalues['status'] != resolved_id:
+        return
+
+    # check the assignedto
+    assignedto = newvalues.get('assignedto', cl.get(nodeid, 'assignedto'))
+    creator = cl.get(nodeid, 'creator')
+    if assignedto == creator:
+        if db.getuid() != creator:
+            name = db.user.get(creator, 'username')
+            raise Reject, 'Only the creator (%s) may close this issue'%name
+        return
+
+    # set the assignedto and status
+    newvalues['assignedto'] = creator
+    try:
+        status = db.status.lookup('confirm-done')
+    except KeyError:
+        status = db.status.lookup('in-progress')
+    newvalues['status'] = status
+
+def init(db):
+    db.issue.audit('set', creator_resolution)
+
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/detectors/emailauditor.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/detectors/emailauditor.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,42 @@
+
+def eml_to_mht(db, cl, nodeid, newvalues):
+    '''This auditor fires whenever a new file entity is created.
+
+    If the file is of type message/rfc822, we tack onthe extension .eml.
+
+    The reason for this is that Microsoft Internet Explorer will not open
+    things with a .eml attachment, as they deem it 'unsafe'. Worse yet,
+    they'll just give you an incomprehensible error message. For more 
+    information, please see: 
+
+    http://support.microsoft.com/default.aspx?scid=kb;EN-US;825803
+
+    Their suggested work around is (excerpt):
+
+     WORKAROUND
+
+     To work around this behavior, rename the .EML file that the URL
+     links to so that it has a .MHT file name extension, and then update
+     the URL to reflect the change to the file name. To do this:
+
+     1. In Windows Explorer, locate and then select the .EML file that
+        the URL links.
+     2. Right-click the .EML file, and then click Rename.
+     3. Change the file name so that the .EML file uses a .MHT file name
+        extension, and then press ENTER.
+     4. Updated the URL that links to the file to reflect the new file
+        name extension.
+
+    So... we do that. :)'''
+    if newvalues.get('type', '').lower() == "message/rfc822":
+        if not newvalues.has_key('name'):
+            newvalues['name'] = 'email.mht'
+            return
+        name = newvalues['name']
+        if name.endswith('.eml'):
+            name = name[:-4]
+        newvalues['name'] = name + '.mht'
+
+def init(db):
+    db.file.audit('create', eml_to_mht)
+

Added: tracker/vendor/roundup/current/detectors/newissuecopy.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/detectors/newissuecopy.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,22 @@
+# copied from nosyreaction
+
+from roundup import roundupdb
+
+def newissuecopy(db, cl, nodeid, oldvalues):
+    ''' Copy a message about new issues to a team address.
+    '''
+    # so use all the messages in the create
+    change_note = cl.generateCreateNote(nodeid)
+
+    # send a copy to the nosy list
+    for msgid in cl.get(nodeid, 'messages'):
+        try:
+            # note: last arg must be a list
+            cl.send_message(nodeid, msgid, change_note, ['team at team.host'])
+        except roundupdb.MessageSendError, message:
+            raise roundupdb.DetectorError, message
+
+def init(db):
+    db.issue.react('create', newissuecopy)
+
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/doc/.cvsignore
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/.cvsignore	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,21 @@
+announcement.html
+customizing.html
+developers.html
+implementation.html
+index.html
+installation.html
+user_guide.html
+FAQ.html
+security.html
+features.html
+upgrading.html
+glossary.html
+design.html
+admin_guide.html
+overview.html
+mysql.html
+postgresql.html
+tracker_templates.html
+whatsnew-0.7.html
+whatsnew-0.8.html
+*.ht

Added: tracker/vendor/roundup/current/doc/FAQ.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/FAQ.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,212 @@
+===========
+Roundup FAQ
+===========
+
+:Version: $Revision: 1.22 $
+
+.. contents::
+
+
+Installation
+------------
+
+Living without a mailserver
+~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Remove the nosy reactor, means delete the tracker file
+``detectors/nosyreactor.py`` from your tracker home.
+
+
+The cgi-bin is very slow!
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Yep, it sure is. It has to start up Python and load all of the support
+libraries for *every* request.
+
+The solution is to use the built in server.
+
+To make Roundup more seamless with your website, you may place the built
+in server behind apache and link it into your web tree
+
+
+How do I put Roundup behind Apache
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+We have a project (foo) running on ``tracker.example:8080``.
+We want ``http://tracker.example/issues`` to use the roundup server, so we 
+set that up on port 8080 on ``tracker.example`` with the ``config.ini`` line::
+
+  [tracker]
+  ...
+  web = 'http://tracker.example/issues/'
+
+We have a "foo_issues" tracker and we run the server with::
+
+  roundup-server -p 8080 issues=/home/roundup/trackers/issues 
+
+Then, on the Apache machine (eg. redhat 7.3 with apache 1.3), in
+``/etc/httpd/conf/httpd.conf`` uncomment::
+
+  LoadModule proxy_module       modules/libproxy.so
+
+and::
+
+  AddModule mod_proxy.c
+
+Then add::
+
+  # roundup stuff (added manually)
+  <IfModule mod_proxy.c>
+  # proxy through one tracker
+  ProxyPass /issues/ http://tracker.example:8080/issues/
+  # proxy through all tracker(*)
+  #ProxyPass /roundup/ http://tracker.example:8080/
+  </IfModule>
+
+Then restart Apache. Now Apache will proxy the request on to the
+roundup-server.
+
+Note that if you're proxying multiple trackers, you'll need to use the
+second ProxyPass rule described above. It will mean that your TRACKER_WEB
+will change to::
+
+  TRACKER_WEB = 'http://tracker.example/roundup/issues/'
+
+Once you're done, you can firewall off port 8080 from the rest of the world.
+
+Note that in some situations (eg. virtual hosting) you might need to use a
+more complex rewrite rule instead of the simpler ProxyPass above. The
+following should be useful as a starting template::
+
+  # roundup stuff (added manually)
+  <IfModule mod_proxy.c>
+
+  RewriteEngine on
+  
+  # General Roundup
+  RewriteRule ^/roundup$  roundup/    [R]
+  RewriteRule ^/roundup/(.*)$ http://tracker.example:8080/$1   [P,L]
+  
+  # Handle Foo Issues
+  RewriteRule ^/issues$  issues/    [R]
+  RewriteRule ^/issues/(.*)$ http://tracker.example:8080/issues/$1 [P,L]
+  
+  </IfModule>
+
+
+How do I run Roundup through SSL (HTTPS)?
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+You should proxy through apache and use its SSL service. See the previous
+question on how to proxy through apache.
+
+
+Roundup runs very slowly on my XP machine when accessed from the Internet
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The issue is probably related to host name resolution for the client
+performing the request. You can turn off the resolution of the names
+when it's so slow like this. To do so, edit the module
+roundup/scripts/roundup_server.py around line 77 to add the following
+to the RoundupRequestHandler class:
+
+     def address_string(self):
+         return self.client_address[0]
+
+
+Templates
+---------
+
+What is that stuff in the tracker html directory?
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+This is the template code that Roundup uses to display the various pages.
+This is based upon the template markup language in Zope called, oddly
+enough "Zope Page Templates". There's documentation in the Roundup
+customisation_ documentation. For more information have a look at:
+
+   http://www.zope.org/Documentation/Books/ZopeBook/2_6Edition/ 
+
+specifically chapter 10 "Using Zope Page Templates" and chapter 14 "Advanced
+Page Templates".
+
+
+But I just want a select/option list for ....
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Really easy... edit ``html/issue.item``. For 'nosy', change line 53 from::
+
+  <span tal:replace="structure context/nosy/field" />
+
+to::
+
+  <span tal:replace="structure context/nosy/menu" />
+
+For 'assigned to', change line 61 from::
+
+  <td tal:content="structure context/assignedto/field">assignedto menu</td>
+
+to::
+
+  <td tal:content="structure context/assignedto/menu">assignedto menu</td>
+
+
+
+Great! But now the select/option list is too big
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Thats a little harder (but only a little ;^)
+
+Again, edit ``html/issue.item``. For nosy, change line 53 from:
+
+  <span tal:replace="structure context/nosy/field" />
+
+to::
+
+  <span tal:replace="structure python:context.nosy.menu(height=3)" />
+
+for more information, go and read about Zope Page Templates.
+
+
+Using Roundup
+-------------
+
+I got an error and I cant reload it!
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+If you're using Netscape/Mozilla, try holding shift and pressing reload.
+If you're using IE then install Mozilla and try again ;^)
+
+
+I keep getting logged out
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Make sure that the ``tracker`` -> ``web`` setting in your tracker's
+config.ini is set to the URL of the tracker.
+
+
+How is sorting performed, and why does it seem to fail sometimes?
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+When we sort items in the hyperdb, we use one of a number of methods,
+depending on the properties being sorted on:
+
+1. If it's a String, Number, Date or Interval property, we just sort the
+   scalar value of the property. Strings are sorted case-sensitively.
+2. If it's a Link property, we sort by either the linked item's "order"
+   property (if it has one) or the linked item's "id".
+3. Mulitlinks sort similar to #2, but we start with the first
+   Multilink list item, and if they're the same, we sort by the second item,
+   and so on.
+
+Note that if an "order" property is defined on a Class that is used for
+sorting, all items of that Class *must* have a value against the "order"
+property, or sorting will result in random ordering.
+
+-----------------
+
+Back to `Table of Contents`_
+
+.. _`Table of Contents`: index.html
+.. _`customisation`: customizing.html
+

Added: tracker/vendor/roundup/current/doc/Makefile
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/Makefile	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,25 @@
+STXTOHTML = rst2html.py
+STXTOHT = rst2ht.py
+WEBDIR = ../../htdocs/htdocs/doc-1.0
+
+SOURCE = announcement.txt customizing.txt developers.txt FAQ.txt features.txt \
+    glossary.txt implementation.txt index.txt design.txt mysql.txt \
+    installation.txt upgrading.txt user_guide.txt admin_guide.txt \
+	postgresql.txt tracker_templates.txt
+
+COMPILED := $(SOURCE:.txt=.html)
+WEBHT := $(SOURCE:.txt=.ht)
+
+all: ${COMPILED} ${WEBHT}
+
+website: ${WEBHT}
+	cp *.ht ${WEBDIR}
+
+%.html: %.txt
+	${STXTOHTML} --report=warning -d $< $@
+
+%.ht: %.txt
+	${STXTOHT} --report=warning -d $< $@
+
+clean:
+	rm -f ${COMPILED}

Added: tracker/vendor/roundup/current/doc/ZPL.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/ZPL.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,59 @@
+Zope Public License (ZPL) Version 2.0
+-----------------------------------------------
+
+This software is Copyright (c) Zope Corporation (tm) and
+Contributors. All rights reserved.
+
+This license has been certified as open source. It has also
+been designated as GPL compatible by the Free Software
+Foundation (FSF).
+
+Redistribution and use in source and binary forms, with or
+without modification, are permitted provided that the
+following conditions are met:
+
+1. Redistributions in source code must retain the above
+   copyright notice, this list of conditions, and the following
+   disclaimer.
+
+2. Redistributions in binary form must reproduce the above
+   copyright notice, this list of conditions, and the following
+   disclaimer in the documentation and/or other materials
+   provided with the distribution.
+
+3. The name Zope Corporation (tm) must not be used to
+   endorse or promote products derived from this software
+   without prior written permission from Zope Corporation.
+
+4. The right to distribute this software or to use it for
+   any purpose does not give you the right to use Servicemarks
+   (sm) or Trademarks (tm) of Zope Corporation. Use of them is
+   covered in a separate agreement (see
+   http://www.zope.com/Marks).
+
+5. If any files are modified, you must cause the modified
+   files to carry prominent notices stating that you changed
+   the files and the date of any change.
+
+Disclaimer
+
+  THIS SOFTWARE IS PROVIDED BY ZOPE CORPORATION ``AS IS''
+  AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT
+  NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
+  AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.  IN
+  NO EVENT SHALL ZOPE CORPORATION OR ITS CONTRIBUTORS BE
+  LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+  EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+  LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+  LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+  HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+  CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
+  OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+  SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH
+  DAMAGE.
+
+
+This software consists of contributions made by Zope
+Corporation and many individuals on behalf of Zope
+Corporation.  Specific attributions are listed in the
+accompanying credits file.

Added: tracker/vendor/roundup/current/doc/admin_guide.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/admin_guide.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,357 @@
+====================
+Administration Guide
+====================
+
+:Version: $Revision: 1.20 $
+
+.. contents::
+
+What does Roundup install?
+==========================
+
+There's two "installations" that we talk about when using Roundup:
+
+1. The installation of the software and its support files. This uses the
+   standard Python mechanism called "distutils" and thus Roundup's core code,
+   executable scripts and support data files are installed in Python's
+   directories. On Windows, this is typically:
+
+   Scripts
+     ``<python dir>\scripts\...``
+   Core code
+     ``<python dir>\lib\site-packages\roundup\...``
+   Support files
+     ``<python dir>\share\roundup\...``
+
+   and on Unix-like systems (eg. Linux):
+
+   Scripts
+     ``<python root>/bin/...``
+   Core code
+     ``<python root>/lib-<python version>/site-packages/roundup/...``
+   Support files
+     ``<python root>/share/roundup/...``
+
+2. The installation of a specific tracker. When invoking the roundup-admin
+   "inst" (and "init") commands, you're creating a new Roundup tracker. This
+   installs configuration files, HTML templates, detector code and a new
+   database. You have complete control over where this stuff goes through
+   both choosing your "tracker home" and the ``main`` -> ``database`` variable
+   in the tracker's config.ini. 
+
+
+Configuring Roundup's Logging of Messages For Sysadmins
+=======================================================
+
+You may configure where Roundup logs messages in your tracker's config.ini
+file. Roundup will use the standard Python (2.3+) logging implementation
+when available. If not, then a very basic logging implementation will be used
+(see BasicLogging in the roundup.rlog module for details).
+
+Configuration for standard "logging" module:
+ - tracker configuration file specifies the location of a logging
+   configration file as ``logging`` -> ``config``
+ - ``roundup-server`` specifies the location of a logging configuration
+   file on the command line
+Configuration for "BasicLogging" implementation:
+ - tracker configuration file specifies the location of a log file
+   ``logging`` -> ``filename``
+ - tracker configuration file specifies the level to log to as
+   ``logging`` -> ``level``
+ - ``roundup-server`` specifies the location of a log file on the command
+   line
+ - ``roundup-server`` specifies the level to log to on the command line
+
+(``roundup-mailgw`` always logs to the tracker's log file)
+
+In both cases, if no logfile is specified then logging will simply be sent
+to sys.stderr with only logging of ERROR messages.
+
+
+Configuring roundup-server
+==========================
+
+The basic configuration file layout is as follows (take from the
+``roundup-server.ini.example`` file in the "doc" directory)::
+
+    [main]
+    port = 8080
+    ;hostname = 
+    ;user = 
+    ;group = 
+    ;log_ip = yes
+    ;pidfile = 
+    ;logfile = 
+
+    [trackers]
+    ; Add one of these per tracker being served
+    name = /path/to/tracker/name
+
+Values ";commented out" are optional. The meaning of the various options
+are as follows:
+
+**port**
+  Defines the local TCP port to listen for clients on.
+**hostname**
+  Defines the local hostname to listen for clients on. Only required if
+  "localhost" is not sufficient.
+**user** and **group**
+  Defines the Unix user and group to run the server as. Only work if the
+  server is started as root.
+**log_ip**
+  If ``yes`` then we log IP addresses against accesses. If ``no`` then we
+  log the hostname of the client. The latter can be much slower.
+**pidfile**
+  If specified, the server will fork at startup and write its new PID to
+  the file.
+**logfile**
+  Any unhandled exception messages or other output from Roundup will be
+  written to this file. It must be specified if **pidfile** is specified.
+  If per-tracker logging is specified, then very little will be written to
+  this file.
+**trackers** section
+  Each line denotes a mapping from a URL component to a tracker home.
+  Make sure the name part doesn't include any url-unsafe characters like
+  spaces. Stick to alphanumeric characters and you'll be ok.
+
+
+Users and Security
+==================
+
+Roundup holds its own user database which primarily contains a username,
+password and email address for the user. Roundup *must* have its own user
+listing, in order to maintain internal consistency of its data. It is a
+relatively simple exercise to update this listing on a regular basis, or on
+demand, so that it matches an external listing (eg. unix passwd file, LDAP,
+etc.)
+
+Roundup identifies users in a number of ways:
+
+1. Through the web, users may be identified by either HTTP Basic
+   Authentication or cookie authentication. If you are running the web
+   server (roundup-server) through another HTTP server (eg. apache or IIS)
+   then that server may require HTTP Basic Authentication, and it will pass
+   the ``REMOTE_USER`` variable through to Roundup. If this variable is not
+   present, then Roundup defaults to using its own cookie-based login
+   mechanism.
+2. In email messages handled by roundup-mailgw, users are identified by the
+   From address in the message.
+
+In both cases, Roundup's behaviour when dealing with unknown users is
+controlled by Permissions defined in the "SECURITY SETTINGS" section of the
+tracker's ``schema.py`` module:
+
+Web Registration
+  If granted to the Anonymous Role, then anonymous users will be able to
+  register through the web.
+Email Registration
+  If granted to the Anonymous Role, then email messages from unknown users
+  will result in those users being registered with the tracker.
+
+More information about how to customise your tracker's security settings
+may be found in the `customisation documentation`_.
+
+
+Tasks
+=====
+
+Maintenance of Roundup can involve one of the following:
+
+1. `tracker backup`_ 
+2. `software upgrade`_
+3. `migrating backends`_
+4. `moving a tracker`_
+5. `migrating from other software`_
+6. `adding a user from the command-line`_
+
+
+Tracker Backup
+--------------
+
+Stop the web and email frontends and to copy the contents of the tracker home
+directory to some other place using standard backup tools.
+
+
+Software Upgrade
+----------------
+
+Always make a backup of your tracker before upgrading software. Steps you may
+take:
+
+1. Ensure that the unit tests run on your system::
+
+    python run_tests.py
+
+2. If you're using an RDBMS backend, make a backup of its contents now.
+3. Make a backup of the tracker home itself.
+4. Stop the tracker web and email frontends.
+5. Follow the steps in the `upgrading documentation`_ for the new version of
+   the software in the copied.
+6. You may test each of the admin tool, web interface and mail gateway using
+   the new version of the software. To do this, invoke the scripts directly
+   in the source directory with::
+
+    PYTHONPATH=. python roundup/scripts/roundup_server.py <normal arguments>
+    PYTHONPATH=. python roundup/scripts/roundup_admin.py <normal arguments>
+    PYTHONPATH=. python roundup/scripts/roundup_mailgw.py <normal arguments>
+
+   Note that on Windows, this would read::
+
+    C:\sources\roundup-0.7.4> SET PYTHONPATH=.
+    C:\sources\roundup-0.7.4> python roundup/scripts/roundup_server.py <normal arguments>
+
+7. Once you're comfortable that the upgrade will work using that copy, you
+   should install the new version of the software::
+
+    python setup.py install
+
+8. Restart your tracker web and email frontends.
+
+If something bad happens, you may reinstate your backup of the tracker and
+reinstall the older version of the sofware using the same install command::
+
+    python setup.py install
+
+
+Migrating Backends
+------------------
+
+1. stop the existing tracker web and email frontends (preventing changes)
+2. use the roundup-admin tool "export" command to export the contents of
+   your tracker to disk
+3. copy the tracker home to a new directory
+4. delete the "db" directory from the new directory
+5. enter the new backend name in the tracker home ``db/backend_name`` file
+6. use the roundup-admin "import" command to import the previous export with
+   the new tracker home
+7. test each of the admin tool, web interface and mail gateway using the new
+   backend
+8. move the old tracker home out of the way (rename to "tracker.old") and 
+   move the new tracker home into its place
+9. restart web and email frontends
+
+
+Moving a Tracker
+----------------
+
+If you're moving the tracker to a similar machine, you should:
+
+1. install Roundup on the new machine and test that it works there,
+2. stop the existing tracker web and email frontends (preventing changes),
+3. copy the tracker home directory over to the new machine, and
+4. start the tracker web and email frontends on the new machine.
+
+Most of the backends are actually portable across platforms (ie. from Unix to
+Windows to Mac). If this isn't the case (ie. the tracker doesn't work when
+moved using the above steps) then you'll need to:
+
+1. install Roundup on the new machine and test that it works there,
+2. stop the existing tracker web and email frontends (preventing changes),
+3. use the roundup-admin tool "export" command to export the contents of
+   the existing tracker,
+4. copy the export to the new machine,
+5. use the roundup-admin "import" command to import the tracker on the new
+   machine, and
+6. start the tracker web and email frontends on the new machine.
+
+
+Migrating From Other Software
+-----------------------------
+
+You have a couple of choices. You can either use a CSV import into Roundup,
+or you can write a simple Python script which uses the Roundup API
+directly. The latter is almost always simpler -- see the "scripts"
+directory in the Roundup source for some example uses of the API.
+
+"roundup-admin import" will import data into your tracker from a 
+directory containing files with the following format:
+
+- one colon-separated-values file per Class with columns for each property,
+  named <classname>.csv
+- one colon-separated-values file per Class with journal information,
+  named <classname>-journals.csv (this is required, even if it's empty)
+- if the Class is a FileClass, you may have the "content" property 
+  stored in separate files from the csv files. This goes in a directory
+  structure::
+
+      <classname>-files/<N>/<designator>
+
+  where ``<designator>`` is the item's ``<classname><id>`` combination.
+  The ``<N>`` value is ``int(<id> / 1000)``.
+
+
+Adding A User From The Command-Line
+-----------------------------------
+
+The ``roundup-admin`` program can create any data you wish to in the
+database. To create a new user, use::
+
+    roundup-admin create user
+
+To figure out what good values might be for some of the fields (eg. Roles)
+you can just display another user::
+
+    roundup-admin list user
+
+(or if you know their username, and it happens to be "richard")::
+
+    roundup-admin find username=richard
+
+then using the user id you get from one of the above commands, you may
+display the user's details::
+
+    roundup-admin display <userid>
+
+
+Running the Servers
+===================
+
+Unix
+----
+
+On Unix systems, use the scripts/server-ctl script to control the
+roundup-server server. Copy it somewhere and edit the variables at the top
+to reflect your specific installation.
+
+
+Windows
+-------
+
+On Windows, the roundup-server program runs as a Windows Service, and 
+therefore may be controlled through the Services control panel. The
+roundup-server program may also control the service directly:
+
+**install the service**
+  ``roundup-server -c install``
+**start the service**
+  ``roundup-server -c start``
+**stop the service**
+  ``roundup-server -c stop``
+
+To bring up the services panel:
+
+Windows 2000 and later
+  Start/Control Panel/Administrative Tools/Services
+Windows NT4
+  Start/Control Panel/Services
+
+Running the Mail Gateway Script
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The mail gateway script should be scheduled to run regularly on your
+Windows server. Normally this will result in a window popping up. The
+solution to this is to:
+
+1. Create a new local account on the Roundup server
+2. Set the scheduled task to run in the context of this user instead
+   of your normal login
+
+
+-------------------
+
+Back to `Table of Contents`_
+
+.. _`Table of Contents`: index.html
+.. _`customisation documentation`: customizing.html
+.. _`upgrading documentation`: upgrading.html
+

Added: tracker/vendor/roundup/current/doc/announcement.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/announcement.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,72 @@
+I'm proud to release version 1.1.2 of Roundup.
+
+Feature:
+
+- server-ctl script uses server configuration file (sf bug 1443805)
+
+Fixed:
+
+- indexing may be turned off for FileClass "content" now
+  ("content" and "type" properties are now automatically included in the
+  FileClass schema where previously the "content" property was faked and
+  "type" was optional)
+- reduced frequency of session timestamp update
+- progress display in roundup-admin reindex
+- bug in menu() permission filter (sf bug 1444440)
+- verbose output during import is optional now (sf bug 1475624)
+- escape *all* uses of "schema" in mysql backend (sf bug 1472120)
+- responses to user rego email (sf bug 1470254)
+- dangling connections in session handling (sf bug 1463359)
+- classhelp popup pagination forgot about "type" (sf bug 1465836)
+- umask is now configurable (with the same 0002 default)
+- sorting of entries in classhelp popup (sf bug 1449000)
+- allow single digit seconds in date spec (sf bug 1447141)
+- prevent generation of new single-digit seconds dates (sf bug 1429390)
+- implement close() on all indexers (sf bug 1242477)
+
+
+If you're upgrading from an older version of Roundup you *must* follow
+the "Software Upgrade" guidelines given in the maintenance documentation.
+
+Roundup requires python 2.3 or later for correct operation.
+
+To give Roundup a try, just download (see below), unpack and run::
+
+    python demo.py
+
+Release info and download page:
+     http://cheeseshop.python.org/pypi/roundup
+Source and documentation is available at the website:
+     http://roundup.sourceforge.net/
+Mailing lists - the place to ask questions:
+     http://sourceforge.net/mail/?group_id=31577
+
+
+About Roundup
+=============
+
+Roundup is a simple-to-use and -install issue-tracking system with
+command-line, web and e-mail interfaces. It is based on the winning design
+from Ka-Ping Yee in the Software Carpentry "Track" design competition.
+
+Note: Ping is not responsible for this project. The contact for this
+project is richard at users.sourceforge.net.
+
+Roundup manages a number of issues (with flexible properties such as
+"description", "priority", and so on) and provides the ability to:
+
+(a) submit new issues,
+(b) find and edit existing issues, and
+(c) discuss issues with other participants.
+
+The system will facilitate communication among the participants by managing
+discussions and notifying interested parties when issues are edited. One of
+the major design goals for Roundup that it be simple to get going. Roundup
+is therefore usable "out of the box" with any python 2.3+ installation. It
+doesn't even need to be "installed" to be operational, though a
+disutils-based install script is provided.
+
+It comes with two issue tracker templates (a classic bug/feature tracker and
+a minimal skeleton) and five database back-ends (anydbm, sqlite, metakit,
+mysql and postgresql).
+

Added: tracker/vendor/roundup/current/doc/customizing.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/customizing.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,4549 @@
+===================
+Customising Roundup
+===================
+
+:Version: $Revision: 1.197 $
+
+.. This document borrows from the ZopeBook section on ZPT. The original is at:
+   http://www.zope.org/Documentation/Books/ZopeBook/current/ZPT.stx
+
+.. contents::
+   :depth: 1
+
+What You Can Do
+===============
+
+Before you get too far, it's probably worth having a quick read of the Roundup
+`design documentation`_.
+
+Customisation of Roundup can take one of six forms:
+
+1. `tracker configuration`_ changes
+2. database, or `tracker schema`_ changes
+3. "definition" class `database content`_ changes
+4. behavioural changes, through detectors_
+5. `security / access controls`_
+6. change the `web interface`_
+
+The third case is special because it takes two distinctly different forms
+depending upon whether the tracker has been initialised or not. The other two
+may be done at any time, before or after tracker initialisation. Yes, this
+includes adding or removing properties from classes.
+
+
+Trackers in a Nutshell
+======================
+
+Trackers have the following structure:
+
+=================== ========================================================
+Tracker File        Description
+=================== ========================================================
+config.ini          Holds the basic `tracker configuration`_                 
+schema.py           Holds the `tracker schema`_                              
+initial_data.py     Holds any data to be entered into the database when the
+                    tracker is initialised.
+db/                 Holds the tracker's database                             
+db/files/           Holds the tracker's upload files and messages            
+db/backend_name     Names the database back-end for the tracker            
+detectors/          Auditors and reactors for this tracker                   
+extensions/         Additional web actions and templating utilities.
+html/               Web interface templates, images and style sheets         
+=================== ======================================================== 
+
+
+Tracker Configuration
+=====================
+
+The ``config.ini`` located in your tracker home contains the basic
+configuration for the web and e-mail components of roundup's interfaces.
+
+Changes to the data captured by your tracker is controlled by the `tracker
+schema`_.  Some configuration is also performed using permissions - see the 
+`security / access controls`_ section. For example, to allow users to
+automatically register through the email interface, you must grant the
+"Anonymous" Role the "Email Access" Permission.
+
+The following is taken from the `Python Library Reference`__ (May 20, 2004)
+section "ConfigParser -- Configuration file parser":
+
+ The configuration file consists of sections, led by a "[section]" header
+ and followed by "name = value" entries, with line continuations on a
+ newline with leading whitespace. Note that leading whitespace is removed
+ from values. The optional values can contain format strings which
+ refer to other values in the same section. Lines beginning with "#" or ";"
+ are ignored and may be used to provide comments. 
+
+ For example::
+
+   [My Section]
+   foodir = %(dir)s/whatever
+   dir = frob
+
+ would resolve the "%(dir)s" to the value of "dir" ("frob" in this case)
+ resulting in "foodir" being "frob/whatever".
+
+__ http://docs.python.org/lib/module-ConfigParser.html
+
+Section **main**
+ database -- ``db``
+  Database directory path. The path may be either absolute or relative
+  to the directory containig this config file.
+
+ templates -- ``html``
+  Path to the HTML templates directory. The path may be either absolute
+  or relative to the directory containig this config file.
+
+ admin_email -- ``roundup-admin``
+  Email address that roundup will complain to if it runs into trouble. If
+  the email address doesn't contain an ``@`` part, the MAIL_DOMAIN defined
+  below is used.
+
+ dispatcher_email -- ``roundup-admin``
+  The 'dispatcher' is a role that can get notified of new items to the
+  database. It is used by the ERROR_MESSAGES_TO config setting. If the
+  email address doesn't contain an ``@`` part, the MAIL_DOMAIN defined
+  below is used.
+
+ email_from_tag -- default *blank*
+  Additional text to include in the "name" part of the From: address used
+  in nosy messages. If the sending user is "Foo Bar", the From: line
+  is usually: ``"Foo Bar" <issue_tracker at tracker.example>``
+  the EMAIL_FROM_TAG goes inside the "Foo Bar" quotes like so:
+  ``"Foo Bar EMAIL_FROM_TAG" <issue_tracker at tracker.example>``
+
+ new_web_user_roles -- ``User``
+  Roles that a user gets when they register with Web User Interface.
+  This is a comma-separated list of role names (e.g. ``Admin,User``).
+
+ new_email_user_roles -- ``User``
+  Roles that a user gets when they register with Email Gateway.
+  This is a comma-separated string of role names (e.g. ``Admin,User``).
+
+ error_messages_to -- ``user``
+  Send error message emails to the ``dispatcher``, ``user``, or ``both``?
+  The dispatcher is configured using the DISPATCHER_EMAIL setting.
+  Allowed values: ``dispatcher``, ``user``, or ``both``
+
+ html_version -- ``html4``
+  HTML version to generate. The templates are ``html4`` by default.
+  If you wish to make them xhtml, then you'll need to change this
+  var to ``xhtml`` too so all auto-generated HTML is compliant.
+  Allowed values: ``html4``, ``xhtml``
+
+ timezone -- ``0``
+  Numeric timezone offset used when users do not choose their own
+  in their settings.
+
+ instant_registration -- ``yes``
+  Register new users instantly, or require confirmation via
+  email?
+  Allowed values: ``yes``, ``no``
+
+ email_registration_confirmation -- ``yes``
+  Offer registration confirmation by email or only through the web?
+  Allowed values: ``yes``, ``no``
+
+ indexer_stopwords -- default *blank*
+  Additional stop-words for the full-text indexer specific to
+  your tracker. See the indexer source for the default list of
+  stop-words (e.g. ``A,AND,ARE,AS,AT,BE,BUT,BY, ...``).
+
+Section **tracker**
+ name -- ``Roundup issue tracker``
+  A descriptive name for your roundup instance.
+
+ web -- ``http://host.example/demo/``
+  The web address that the tracker is viewable at.
+  This will be included in information sent to users of the tracker.
+  The URL MUST include the cgi-bin part or anything else
+  that is required to get to the home page of the tracker.
+  You MUST include a trailing '/' in the URL.
+
+ email -- ``issue_tracker``
+  Email address that mail to roundup should go to.
+
+Section **web**
+ http_auth -- ``yes``
+  Whether to use HTTP Basic Authentication, if present.
+  Roundup will use either the REMOTE_USER or HTTP_AUTHORIZATION
+  variables supplied by your web server (in that order).
+  Set this option to 'no' if you do not wish to use HTTP Basic
+  Authentication in your web interface.
+
+ use_browser_language -- ``yes``
+  Whether to use HTTP Accept-Language, if present.
+  Browsers send a language-region preference list.
+  It's usually set in the client's browser or in their
+  Operating System.
+  Set this option to 'no' if you want to ignore it.
+
+ debug -- ``no``
+  Setting this option makes Roundup display error tracebacks
+  in the user's browser rather than emailing them to the
+  tracker admin."),
+
+Section **rdbms**
+ Settings in this section are used by Postgresql and MySQL backends only
+
+ name -- ``roundup``
+  Name of the database to use.
+
+ host -- ``localhost``
+  Database server host.
+
+ port -- default *blank*
+  TCP port number of the database server. Postgresql usually resides on
+  port 5432 (if any), for MySQL default port number is 3306. Leave this
+  option empty to use backend default.
+
+ user -- ``roundup``
+  Database user name that Roundup should use.
+
+ password -- ``roundup``
+  Database user password.
+
+Section **logging**
+ config -- default *blank*
+  Path to configuration file for standard Python logging module. If this
+  option is set, logging configuration is loaded from specified file;
+  options 'filename' and 'level' in this section are ignored. The path may
+  be either absolute or relative to the directory containig this config file.
+
+ filename -- default *blank*
+  Log file name for minimal logging facility built into Roundup.  If no file
+  name specified, log messages are written on stderr. If above 'config'
+  option is set, this option has no effect. The path may be either absolute
+  or relative to the directory containig this config file.
+
+ level -- ``ERROR``
+  Minimal severity level of messages written to log file. If above 'config'
+  option is set, this option has no effect.
+  Allowed values: ``DEBUG``, ``INFO``, ``WARNING``, ``ERROR``
+
+Section **mail**
+ Outgoing email options. Used for nosy messages, password reset and
+ registration approval requests.
+
+ domain -- ``localhost``
+  Domain name used for email addresses.
+
+ host -- default *blank*
+  SMTP mail host that roundup will use to send mail
+
+ username -- default *blank*
+  SMTP login name. Set this if your mail host requires authenticated access.
+  If username is not empty, password (below) MUST be set!
+
+ password -- default *blank*
+  SMTP login password.
+  Set this if your mail host requires authenticated access.
+
+ tls -- ``no``
+  If your SMTP mail host provides or requires TLS (Transport Layer Security)
+  then you may set this option to 'yes'.
+  Allowed values: ``yes``, ``no``
+
+ tls_keyfile -- default *blank*
+  If TLS is used, you may set this option to the name of a PEM formatted
+  file that contains your private key. The path may be either absolute or
+  relative to the directory containig this config file.
+
+ tls_certfile -- default *blank*
+  If TLS is used, you may set this option to the name of a PEM formatted
+  certificate chain file. The path may be either absolute or relative
+  to the directory containig this config file.
+
+ charset -- utf-8
+  Character set to encode email headers with. We use utf-8 by default, as
+  it's the most flexible. Some mail readers (eg. Eudora) can't cope with
+  that, so you might need to specify a more limited character set
+  (eg. iso-8859-1).
+
+ debug -- default *blank*
+  Setting this option makes Roundup to write all outgoing email messages
+  to this file *instead* of sending them. This option has the same effect
+  as environment variable SENDMAILDEBUG. Environment variable takes
+  precedence. The path may be either absolute or relative to the directory
+  containig this config file.
+
+Section **mailgw**
+ Roundup Mail Gateway options
+
+ keep_quoted_text -- ``yes``
+  Keep email citations when accepting messages. Setting this to ``no`` strips
+  out "quoted" text from the message. Signatures are also stripped.
+  Allowed values: ``yes``, ``no``
+
+ leave_body_unchanged -- ``no``
+  Preserve the email body as is - that is, keep the citations *and*
+  signatures.
+  Allowed values: ``yes``, ``no``
+
+ default_class -- ``issue``
+  Default class to use in the mailgw if one isn't supplied in email subjects.
+  To disable, leave the value blank.
+
+ subject_prefix_parsing -- ``strict``
+  Controls the parsing of the [prefix] on subject lines in incoming emails.
+  ``strict`` will return an error to the sender if the [prefix] is not
+  recognised. ``loose`` will attempt to parse the [prefix] but just
+  pass it through as part of the issue title if not recognised. ``none``
+  will always pass any [prefix] through as part of the issue title.
+
+ subject_suffix_parsing -- ``strict``
+  Controls the parsing of the [suffix] on subject lines in incoming emails.
+  ``strict`` will return an error to the sender if the [suffix] is not
+  recognised. ``loose`` will attempt to parse the [suffix] but just
+  pass it through as part of the issue title if not recognised. ``none``
+  will always pass any [suffix] through as part of the issue title.
+
+ subject_suffix_delimiters -- ``[]``
+  Defines the brackets used for delimiting the commands suffix in a subject
+  line.
+
+ subject_content_match -- ``always``
+  Controls matching of the incoming email subject line against issue titles
+  in the case where there is no designator [prefix]. ``never`` turns off
+  matching. ``creation + interval`` or ``activity + interval`` will match
+  an issue for the interval after the issue's creation or last activity.
+  The interval is a standard Roundup interval.
+
+Section **nosy**
+ Nosy messages sending
+
+ messages_to_author -- ``no``
+  Send nosy messages to the author of the message.
+  Allowed values: ``yes``, ``no``, ``new``
+
+ signature_position -- ``bottom``
+  Where to place the email signature.
+  Allowed values: ``top``, ``bottom``, ``none``
+
+ add_author -- ``new``
+  Does the author of a message get placed on the nosy list automatically?
+  If ``new`` is used, then the author will only be added when a message
+  creates a new issue. If ``yes``, then the author will be added on
+  followups too. If ``no``, they're never added to the nosy.
+  Allowed values: ``yes``, ``no``, ``new``
+  
+ add_recipients -- ``new``
+  Do the recipients (``To:``, ``Cc:``) of a message get placed on the nosy
+  list?  If ``new`` is used, then the recipients will only be added when a
+  message creates a new issue. If ``yes``, then the recipients will be added
+  on followups too. If ``no``, they're never added to the nosy.
+  Allowed values: ``yes``, ``no``, ``new``
+
+ email_sending -- ``single``
+  Controls the email sending from the nosy reactor. If ``multiple`` then
+  a separate email is sent to each recipient. If ``single`` then a single
+  email is sent with each recipient as a CC address.
+
+You may generate a new default config file using the ``roundup-admin
+genconfig`` command.
+
+Configuration variables may be referred to in lower or upper case. In code,
+variables not in the "main" section are referred to using their section and
+name, so "domain" in the section "mail" becomes MAIL_DOMAIN. The
+configuration variables available are:
+
+
+Tracker Schema
+==============
+
+.. note::
+   if you modify the schema, you'll most likely need to edit the
+   `web interface`_ HTML template files and `detectors`_ to reflect
+   your changes.
+
+A tracker schema defines what data is stored in the tracker's database.
+Schemas are defined using Python code in the ``schema.py`` module of your
+tracker.
+
+The ``schema.py`` module
+------------------------
+
+The ``schema.py`` module contains two functions:
+
+**open**
+  This function defines what your tracker looks like on the inside, the
+  **schema** of the tracker. It defines the **Classes** and **properties**
+  on each class. It also defines the **security** for those Classes. The
+  next few sections describe how schemas work and what you can do with
+  them.
+**init**
+  This function is responsible for setting up the initial state of your
+  tracker. It's called exactly once - but the ``roundup-admin initialise``
+  command.  See the start of the section on `database content`_ for more
+  info about how this works.
+
+
+The "classic" schema
+--------------------
+
+The "classic" schema looks like this (see section `setkey(property)`_
+below for the meaning of ``'setkey'`` -- you may also want to look into
+the sections `setlabelprop(property)`_ and `setorderprop(property)`_ for
+specifying (default) labelling and ordering of classes.)::
+
+    pri = Class(db, "priority", name=String(), order=String())
+    pri.setkey("name")
+
+    stat = Class(db, "status", name=String(), order=String())
+    stat.setkey("name")
+
+    keyword = Class(db, "keyword", name=String())
+    keyword.setkey("name")
+
+    user = Class(db, "user", username=String(), organisation=String(),
+        password=String(), address=String(), realname=String(),
+        phone=String())
+    user.setkey("username")
+
+    msg = FileClass(db, "msg", author=Link("user"), summary=String(),
+        date=Date(), recipients=Multilink("user"),
+        files=Multilink("file"))
+
+    file = FileClass(db, "file", name=String(), type=String())
+
+    issue = IssueClass(db, "issue", topic=Multilink("keyword"),
+        status=Link("status"), assignedto=Link("user"),
+        priority=Link("priority"))
+    issue.setkey('title')
+
+
+What you can't do to the schema
+-------------------------------
+
+You must never:
+
+**Remove the users class**
+  This class is the only *required* class in Roundup.
+
+**Remove the "username", "address", "password" or "realname" user properties**
+  Various parts of Roundup require these properties. Don't remove them.
+
+**Change the type of a property**
+  Property types must *never* be changed - the database simply doesn't take
+  this kind of action into account. Note that you can't just remove a
+  property and re-add it as a new type either. If you wanted to make the
+  assignedto property a Multilink, you'd need to create a new property
+  assignedto_list and remove the old assignedto property.
+
+
+What you can do to the schema
+-----------------------------
+
+Your schema may be changed at any time before or after the tracker has been
+initialised (or used). You may:
+
+**Add new properties to classes, or add whole new classes**
+  This is painless and easy to do - there are generally no repurcussions
+  from adding new information to a tracker's schema.
+
+**Remove properties**
+  Removing properties is a little more tricky - you need to make sure that
+  the property is no longer used in the `web interface`_ *or* by the
+  detectors_.
+
+
+
+Classes and Properties - creating a new information store
+---------------------------------------------------------
+
+In the tracker above, we've defined 7 classes of information:
+
+  priority
+      Defines the possible levels of urgency for issues.
+
+  status
+      Defines the possible states of processing the issue may be in.
+
+  keyword
+      Initially empty, will hold keywords useful for searching issues.
+
+  user
+      Initially holding the "admin" user, will eventually have an entry
+      for all users using roundup.
+
+  msg
+      Initially empty, will hold all e-mail messages sent to or
+      generated by roundup.
+
+  file
+      Initially empty, will hold all files attached to issues.
+
+  issue
+      Initially empty, this is where the issue information is stored.
+
+We define the "priority" and "status" classes to allow two things:
+reduction in the amount of information stored on the issue and more
+powerful, accurate searching of issues by priority and status. By only
+requiring a link on the issue (which is stored as a single number) we
+reduce the chance that someone mis-types a priority or status - or
+simply makes a new one up.
+
+
+Class and Items
+~~~~~~~~~~~~~~~
+
+A Class defines a particular class (or type) of data that will be stored
+in the database. A class comprises one or more properties, which gives
+the information about the class items.
+
+The actual data entered into the database, using ``class.create()``, are
+called items. They have a special immutable property called ``'id'``. We
+sometimes refer to this as the *itemid*.
+
+
+Properties
+~~~~~~~~~~
+
+A Class is comprised of one or more properties of the following types:
+
+* String properties are for storing arbitrary-length strings.
+* Password properties are for storing encoded arbitrary-length strings.
+  The default encoding is defined on the ``roundup.password.Password``
+  class.
+* Date properties store date-and-time stamps. Their values are Timestamp
+  objects.
+* Number properties store numeric values.
+* Boolean properties store on/off, yes/no, true/false values.
+* A Link property refers to a single other item selected from a
+  specified class. The class is part of the property; the value is an
+  integer, the id of the chosen item.
+* A Multilink property refers to possibly many items in a specified
+  class. The value is a list of integers.
+
+All Classes automatically have a number of properties by default:
+
+*creator*
+  Link to the user that created the item.
+*creation*
+  Date the item was created.
+*actor*
+  Link to the user that last modified the item.
+*activity*
+  Date the item was last modified.
+
+
+FileClass
+~~~~~~~~~
+
+FileClasses save their "content" attribute off in a separate file from
+the rest of the database. This reduces the number of large entries in
+the database, which generally makes databases more efficient, and also
+allows us to use command-line tools to operate on the files. They are
+stored in the files sub-directory of the ``'db'`` directory in your
+tracker.
+
+
+IssueClass
+~~~~~~~~~~
+
+IssueClasses automatically include the "messages", "files", "nosy", and
+"superseder" properties.
+
+The messages and files properties list the links to the messages and
+files related to the issue. The nosy property is a list of links to
+users who wish to be informed of changes to the issue - they get "CC'ed"
+e-mails when messages are sent to or generated by the issue. The nosy
+reactor (in the ``'detectors'`` directory) handles this action. The
+superseder link indicates an issue which has superseded this one.
+
+They also have the dynamically generated "creation", "activity" and
+"creator" properties.
+
+The value of the "creation" property is the date when an item was
+created, and the value of the "activity" property is the date when any
+property on the item was last edited (equivalently, these are the dates
+on the first and last records in the item's journal). The "creator"
+property holds a link to the user that created the issue.
+
+
+setkey(property)
+~~~~~~~~~~~~~~~~
+
+Select a String property of the class to be the key property. The key
+property must be unique, and allows references to the items in the class
+by the content of the key property. That is, we can refer to users by
+their username: for example, let's say that there's an issue in roundup,
+issue 23. There's also a user, richard, who happens to be user 2. To
+assign an issue to him, we could do either of::
+
+     roundup-admin set issue23 assignedto=2
+
+or::
+
+     roundup-admin set issue23 assignedto=richard
+
+Note, the same thing can be done in the web and e-mail interfaces. 
+
+setlabelprop(property)
+~~~~~~~~~~~~~~~~~~~~~~
+
+Select a property of the class to be the label property. The label
+property is used whereever an item should be uniquely identified, e.g.,
+when displaying a link to an item. If setlabelprop is not specified for
+a class, the following values are tried for the label: 
+
+ * the key of the class (see the `setkey(property)`_ section above)
+ * the "name" property
+ * the "title" property
+ * the first property from the sorted property name list
+
+So in most cases you can get away without specifying setlabelprop
+explicitly.
+
+setorderprop(property)
+~~~~~~~~~~~~~~~~~~~~~~
+
+Select a property of the class to be the order property. The order
+property is used whenever using a default sort order for the class,
+e.g., when grouping or sorting class A by a link to class B in the user
+interface, the order property of class B is used for sorting.  If
+setorderprop is not specified for a class, the following values are tried
+for the order property:
+
+ * the property named "order"
+ * the label property (see `setlabelprop(property)`_ above)
+
+So in most cases you can get away without specifying setorderprop
+explicitly.
+
+create(information)
+~~~~~~~~~~~~~~~~~~~
+
+Create an item in the database. This is generally used to create items
+in the "definitional" classes like "priority" and "status".
+
+
+A note about ordering
+~~~~~~~~~~~~~~~~~~~~~
+
+When we sort items in the hyperdb, we use one of a number of methods,
+depending on the properties being sorted on:
+
+1. If it's a String, Number, Date or Interval property, we just sort the
+   scalar value of the property. Strings are sorted case-sensitively.
+2. If it's a Link property, we sort by either the linked item's "order"
+   property (if it has one) or the linked item's "id".
+3. Mulitlinks sort similar to #2, but we start with the first Multilink
+   list item, and if they're the same, we sort by the second item, and
+   so on.
+
+Note that if an "order" property is defined on a Class that is used for
+sorting, all items of that Class *must* have a value against the "order"
+property, or sorting will result in random ordering.
+
+
+Examples of adding to your schema
+---------------------------------
+
+TODO
+
+
+Detectors - adding behaviour to your tracker
+============================================
+.. _detectors:
+
+Detectors are initialised every time you open your tracker database, so
+you're free to add and remove them any time, even after the database is
+initialised via the ``roundup-admin initialise`` command.
+
+The detectors in your tracker fire *before* (**auditors**) and *after*
+(**reactors**) changes to the contents of your database. They are Python
+modules that sit in your tracker's ``detectors`` directory. You will
+have some installed by default - have a look. You can write new
+detectors or modify the existing ones. The existing detectors installed
+for you are:
+
+**nosyreaction.py**
+  This provides the automatic nosy list maintenance and email sending.
+  The nosy reactor (``nosyreaction``) fires when new messages are added
+  to issues. The nosy auditor (``updatenosy``) fires when issues are
+  changed, and figures out what changes need to be made to the nosy list
+  (such as adding new authors, etc.)
+**statusauditor.py**
+  This provides the ``chatty`` auditor which changes the issue status
+  from ``unread`` or ``closed`` to ``chatting`` if new messages appear.
+  It also provides the ``presetunread`` auditor which pre-sets the
+  status to ``unread`` on new items if the status isn't explicitly
+  defined.
+**messagesummary.py**
+  Generates the ``summary`` property for new messages based on the message
+  content.
+**userauditor.py**
+  Verifies the content of some of the user fields (email addresses and
+  roles lists).
+
+If you don't want this default behaviour, you're completely free to change
+or remove these detectors.
+
+See the detectors section in the `design document`__ for details of the
+interface for detectors.
+
+__ design.html
+
+
+Detector API
+------------
+
+Auditors are called with the arguments::
+
+    audit(db, cl, itemid, newdata)
+
+where ``db`` is the database, ``cl`` is an instance of Class or
+IssueClass within the database, and ``newdata`` is a dictionary mapping
+property names to values.
+
+For a ``create()`` operation, the ``itemid`` argument is None and
+newdata contains all of the initial property values with which the item
+is about to be created.
+
+For a ``set()`` operation, newdata contains only the names and values of
+properties that are about to be changed.
+
+For a ``retire()`` or ``restore()`` operation, newdata is None.
+
+Reactors are called with the arguments::
+
+    react(db, cl, itemid, olddata)
+
+where ``db`` is the database, ``cl`` is an instance of Class or
+IssueClass within the database, and ``olddata`` is a dictionary mapping
+property names to values.
+
+For a ``create()`` operation, the ``itemid`` argument is the id of the
+newly-created item and ``olddata`` is None.
+
+For a ``set()`` operation, ``olddata`` contains the names and previous
+values of properties that were changed.
+
+For a ``retire()`` or ``restore()`` operation, ``itemid`` is the id of
+the retired or restored item and ``olddata`` is None.
+
+
+Additional Detectors Ready For Use
+----------------------------------
+
+Sample additional detectors that have been found useful will appear in
+the ``'detectors'`` directory of the Roundup distribution. If you want
+to use one, copy it to the ``'detectors'`` of your tracker instance:
+
+**newissuecopy.py**
+  This detector sends an email to a team address whenever a new issue is
+  created. The address is hard-coded into the detector, so edit it
+  before you use it (look for the text 'team at team.host') or you'll get
+  email errors!
+**creator_resolution.py**
+  Catch attempts to set the status to "resolved" - if the assignedto
+  user isn't the creator, then set the status to "confirm-done". Note that
+  "classic" Roundup doesn't have that status, so you'll have to add it. If
+  you don't want to though, it'll just use "in-progress" instead.
+**email_auditor.py**
+  If a file added to an issue is of type message/rfc822, we tack on the
+  extension .eml.
+  The reason for this is that Microsoft Internet Explorer will not open
+  things with a .eml attachment, as they deem it 'unsafe'. Worse yet,
+  they'll just give you an incomprehensible error message. For more 
+  information, see the detector code - it has a length explanation.
+
+
+Auditor or Reactor?
+-------------------
+
+Generally speaking, the following rules should be observed:
+
+**Auditors**
+  Are used for `vetoing creation of or changes to items`_. They might
+  also make automatic changes to item properties.
+**Reactors**
+  Detect changes in the database and react accordingly. They should avoid
+  making changes to the database where possible, as this could create
+  detector loops.
+
+
+Vetoing creation of or changes to items
+---------------------------------------
+
+Auditors may raise the ``Reject`` exception to prevent the creation of
+or changes to items in the database.  The mail gateway, for example, will
+not attach files or messages to issues when the creation of those files or
+messages are prevented through the ``Reject`` exception. It'll also not create
+users if that creation is ``Reject``'ed too.
+
+To use, simply add at the top of your auditor::
+
+   from roundup.exceptions import Reject
+
+And then when your rejection criteria have been detected, simply::
+
+   raise Reject
+
+
+Generating email from Roundup
+-----------------------------
+
+The module ``roundup.mailer`` contains most of the nuts-n-bolts required
+to generate email messages from Roundup.
+
+In addition, the ``IssueClass`` methods ``nosymessage()`` and
+``send_message()`` are used to generate nosy messages, and may generate
+messages which only consist of a change note (ie. the message id parameter
+is not required - this is referred to as a "System Message" because it
+comes from "the system" and not a user).
+
+
+Database Content
+================
+
+.. note::
+   if you modify the content of definitional classes, you'll most
+   likely need to edit the tracker `detectors`_ to reflect your changes.
+
+Customisation of the special "definitional" classes (eg. status,
+priority, resolution, ...) may be done either before or after the
+tracker is initialised. The actual method of doing so is completely
+different in each case though, so be careful to use the right one.
+
+**Changing content before tracker initialisation**
+    Edit the initial_data.py module in your tracker to alter the items
+    created using the ``create( ... )`` methods.
+
+**Changing content after tracker initialisation**
+    As the "admin" user, click on the "class list" link in the web
+    interface to bring up a list of all database classes. Click on the
+    name of the class you wish to change the content of.
+
+    You may also use the ``roundup-admin`` interface's create, set and
+    retire methods to add, alter or remove items from the classes in
+    question.
+
+See "`adding a new field to the classic schema`_" for an example that
+requires database content changes.
+
+
+Security / Access Controls
+==========================
+
+A set of Permissions is built into the security module by default:
+
+- Create (everything)
+- Edit (everything)
+- View (everything)
+
+These are assigned to the "Admin" Role by default, and allow a user to do
+anything. Every Class you define in your `tracker schema`_ also gets an
+Create, Edit and View Permission of its own. The web and email interfaces
+also define:
+
+*Email Access*
+  If defined, the user may use the email interface. Used by default to deny
+  Anonymous users access to the email interface. When granted to the
+  Anonymous user, they will be automatically registered by the email
+  interface (see also the ``new_email_user_roles`` configuration option).
+*Web Access*
+  If defined, the user may use the web interface. All users are able to see
+  the login form, regardless of this setting (thus enabling logging in).
+*Web Roles*
+  Controls user access to editing the "roles" property of the "user" class.
+  TODO: deprecate in favour of a property-based control.
+
+These are hooked into the default Roles:
+
+- Admin (Create, Edit, View and everything; Web Roles)
+- User (Web Access; Email Access)
+- Anonymous (Web Access)
+
+And finally, the "admin" user gets the "Admin" Role, and the "anonymous"
+user gets "Anonymous" assigned when the tracker is installed.
+
+For the "User" Role, the "classic" tracker defines:
+
+- Create, Edit and View issue, file, msg, query, keyword 
+- View priority, status
+- View user
+- Edit their own user record
+
+And the "Anonymous" Role is defined as:
+
+- Web interface access
+- Create user (for registration)
+- View issue, file, msg, query, keyword, priority, status
+
+Put together, these settings appear in the tracker's ``schema.py`` file::
+
+    #
+    # TRACKER SECURITY SETTINGS
+    #
+    # See the configuration and customisation document for information
+    # about security setup.
+
+    #
+    # REGULAR USERS
+    #
+    # Give the regular users access to the web and email interface
+    db.security.addPermissionToRole('User', 'Web Access')
+    db.security.addPermissionToRole('User', 'Email Access')
+
+    # Assign the access and edit Permissions for issue, file and message
+    # to regular users now
+    for cl in 'issue', 'file', 'msg', 'query', 'keyword':
+        db.security.addPermissionToRole('User', 'View', cl)
+        db.security.addPermissionToRole('User', 'Edit', cl)
+        db.security.addPermissionToRole('User', 'Create', cl)
+    for cl in 'priority', 'status':
+        db.security.addPermissionToRole('User', 'View', cl)
+
+    # May users view other user information? Comment these lines out
+    # if you don't want them to
+    db.security.addPermissionToRole('User', 'View', 'user')
+
+    # Users should be able to edit their own details -- this permission
+    # is limited to only the situation where the Viewed or Edited item
+    # is their own.
+    def own_record(db, userid, itemid):
+        '''Determine whether the userid matches the item being accessed.'''
+        return userid == itemid
+    p = db.security.addPermission(name='View', klass='user', check=own_record,
+        description="User is allowed to view their own user details")
+    db.security.addPermissionToRole('User', p)
+    p = db.security.addPermission(name='Edit', klass='user', check=own_record,
+        description="User is allowed to edit their own user details")
+    db.security.addPermissionToRole('User', p)
+
+    #
+    # ANONYMOUS USER PERMISSIONS
+    #
+    # Let anonymous users access the web interface. Note that almost all
+    # trackers will need this Permission. The only situation where it's not
+    # required is in a tracker that uses an HTTP Basic Authenticated front-end.
+    db.security.addPermissionToRole('Anonymous', 'Web Access')
+
+    # Let anonymous users access the email interface (note that this implies
+    # that they will be registered automatically, hence they will need the
+    # "Create" user Permission below)
+    # This is disabled by default to stop spam from auto-registering users on
+    # public trackers.
+    #db.security.addPermissionToRole('Anonymous', 'Email Access')
+
+    # Assign the appropriate permissions to the anonymous user's Anonymous
+    # Role. Choices here are:
+    # - Allow anonymous users to register
+    db.security.addPermissionToRole('Anonymous', 'Create', 'user')
+
+    # Allow anonymous users access to view issues (and the related, linked
+    # information)
+    for cl in 'issue', 'file', 'msg', 'keyword', 'priority', 'status':
+        db.security.addPermissionToRole('Anonymous', 'View', cl)
+
+    # [OPTIONAL]
+    # Allow anonymous users access to create or edit "issue" items (and the
+    # related file and message items)
+    #for cl in 'issue', 'file', 'msg':
+    #   db.security.addPermissionToRole('Anonymous', 'Create', cl)
+    #   db.security.addPermissionToRole('Anonymous', 'Edit', cl)
+
+
+Automatic Permission Checks
+---------------------------
+
+Permissions are automatically checked when information is rendered
+through the web. This includes:
+
+1. View checks for properties when being rendered via the ``plain()`` or
+   similar methods. If the check fails, the text "[hidden]" will be
+   displayed.
+2. Edit checks for properties when the edit field is being rendered via
+   the ``field()`` or similar methods. If the check fails, the property
+   will be rendered via the ``plain()`` method (see point 1. for subsequent
+   checking performed)
+3. View checks are performed in index pages for each item being displayed
+   such that if the user does not have permission, the row is not rendered.
+4. View checks are performed at the top of item pages for the Item being
+   displayed. If the user does not have permission, the text "You are not
+   allowed to view this page." will be displayed.
+5. View checks are performed at the top of index pages for the Class being
+   displayed. If the user does not have permission, the text "You are not
+   allowed to view this page." will be displayed.
+
+
+New User Roles
+--------------
+
+New users are assigned the Roles defined in the config file as:
+
+- NEW_WEB_USER_ROLES
+- NEW_EMAIL_USER_ROLES
+
+The `users may only edit their issues`_ example shows customisation of
+these parameters.
+
+
+Changing Access Controls
+------------------------
+
+You may alter the configuration variables to change the Role that new
+web or email users get, for example to not give them access to the web
+interface if they register through email. 
+
+You may use the ``roundup-admin`` "``security``" command to display the
+current Role and Permission configuration in your tracker.
+
+
+Adding a new Permission
+~~~~~~~~~~~~~~~~~~~~~~~
+
+When adding a new Permission, you will need to:
+
+1. add it to your tracker's ``schema.py`` so it is created, using
+   ``security.addPermission``, for example::
+
+    self.security.addPermission(name="View", klass='frozzle',
+        description="User is allowed to access frozzles")
+
+   will set up a new "View" permission on the Class "frozzle".
+2. enable it for the Roles that should have it (verify with
+   "``roundup-admin security``")
+3. add it to the relevant HTML interface templates
+4. add it to the appropriate xxxPermission methods on in your tracker
+   interfaces module
+
+The ``addPermission`` method takes a couple of optional parameters:
+
+**properties**
+  A sequence of property names that are the only properties to apply the
+  new Permission to (eg. ``... klass='user', properties=('name',
+  'email') ...``)
+**check**
+  A function to be execute which returns boolean determining whether the
+  Permission is allowed. The function has the signature ``check(db, userid,
+  itemid)`` where ``db`` is a handle on the open database, ``userid`` is
+  the user attempting access and ``itemid`` is the specific item being
+  accessed.
+
+Example Scenarios
+~~~~~~~~~~~~~~~~~
+
+See the `examples`_ section for longer examples of customisation.
+
+**anonymous access through the e-mail gateway**
+ Give the "anonymous" user the "Email Access", ("Edit", "issue") and
+ ("Create", "msg") Permissions but do not not give them the ("Create",
+ "user") Permission. This means that when an unknown user sends email
+ into the tracker, they're automatically logged in as "anonymous".
+ Since they don't have the ("Create", "user") Permission, they won't
+ be automatically registered, but since "anonymous" has permission to
+ use the gateway, they'll still be able to submit issues. Note that
+ the Sender information - their email address - will not be available
+ - they're *anonymous*.
+
+**automatic registration of users in the e-mail gateway**
+ By giving the "anonymous" user the ("Create", "user" Permission, any
+ unidentified user will automatically be registered with the tracker
+ (with no password, so they won't be able to log in through
+ the web until an admin sets their password). This is the default
+ behaviour in the tracker templates that ship with Roundup. The new user
+ is given the Roles list defined in the "new_email_user_roles" config
+ variable.
+
+**only developers may be assigned issues**
+ Create a new Permission called "Fixer" for the "issue" class. Create a
+ new Role "Developer" which has that Permission, and assign that to the
+ appropriate users. Filter the list of users available in the assignedto
+ list to include only those users. Enforce the Permission with an
+ auditor. See the example 
+ `restricting the list of users that are assignable to a task`_.
+
+**only managers may sign off issues as complete**
+ Create a new Permission called "Closer" for the "issue" class. Create a
+ new Role "Manager" which has that Permission, and assign that to the
+ appropriate users. In your web interface, only display the "resolved"
+ issue state option when the user has the "Closer" Permissions. Enforce
+ the Permission with an auditor. This is very similar to the previous
+ example, except that the web interface check would look like::
+
+   <option tal:condition="python:request.user.hasPermission('Closer')"
+           value="resolved">Resolved</option>
+ 
+**don't give web access to users who register through email**
+ Create a new Role called "Email User" which has all the Permissions of
+ the normal "User" Role minus the "Web Access" Permission. This will
+ allow users to send in emails to the tracker, but not access the web
+ interface.
+
+**let some users edit the details of all users**
+ Create a new Role called "User Admin" which has the Permission for
+ editing users::
+
+    db.security.addRole(name='User Admin', description='Managing users')
+    p = db.security.getPermission('Edit', 'user')
+    db.security.addPermissionToRole('User Admin', p)
+
+ and assign the Role to the users who need the permission.
+
+
+Web Interface
+=============
+
+.. contents::
+   :local:
+
+The web interface is provided by the ``roundup.cgi.client`` module and
+is used by ``roundup.cgi``, ``roundup-server`` and ``ZRoundup``
+(``ZRoundup``  is broken, until further notice). In all cases, we
+determine which tracker is being accessed (the first part of the URL
+path inside the scope of the CGI handler) and pass control on to the
+``roundup.cgi.client.Client`` class - which handles the rest of the
+access through its ``main()`` method. This means that you can do pretty
+much anything you want as a web interface to your tracker.
+
+
+
+Repercussions of changing the tracker schema
+---------------------------------------------
+
+If you choose to change the `tracker schema`_ you will need to ensure
+the web interface knows about it:
+
+1. Index, item and search pages for the relevant classes may need to
+   have properties added or removed,
+2. The "page" template may require links to be changed, as might the
+   "home" page's content arguments.
+
+
+How requests are processed
+--------------------------
+
+The basic processing of a web request proceeds as follows:
+
+1. figure out who we are, defaulting to the "anonymous" user
+2. figure out what the request is for - we call this the "context"
+3. handle any requested action (item edit, search, ...)
+4. render the template requested by the context, resulting in HTML
+   output
+
+In some situations, exceptions occur:
+
+- HTTP Redirect  (generally raised by an action)
+- SendFile       (generally raised by ``determine_context``)
+    here we serve up a FileClass "content" property
+- SendStaticFile (generally raised by ``determine_context``)
+    here we serve up a file from the tracker "html" directory
+- Unauthorised   (generally raised by an action)
+    here the action is cancelled, the request is rendered and an error
+    message is displayed indicating that permission was not granted for
+    the action to take place
+- NotFound       (raised wherever it needs to be)
+    this exception percolates up to the CGI interface that called the
+    client
+
+
+Determining web context
+-----------------------
+
+To determine the "context" of a request, we look at the URL and the
+special request variable ``@template``. The URL path after the tracker
+identifier is examined. Typical URL paths look like:
+
+1.  ``/tracker/issue``
+2.  ``/tracker/issue1``
+3.  ``/tracker/@@file/style.css``
+4.  ``/cgi-bin/roundup.cgi/tracker/file1``
+5.  ``/cgi-bin/roundup.cgi/tracker/file1/kitten.png``
+
+where the "tracker identifier" is "tracker" in the above cases. That means
+we're looking at "issue", "issue1", "@@file/style.css", "file1" and
+"file1/kitten.png" in the cases above. The path is generally only one
+entry long - longer paths are handled differently.
+
+a. if there is no path, then we are in the "home" context. See `the "home"
+   context`_ below for more information about how it may be used.
+b. if the path starts with "@@file" (as in example 3,
+   "/tracker/@@file/style.css"), then the additional path entry,
+   "style.css" specifies the filename of a static file we're to serve up
+   from the tracker TEMPLATES (or STATIC_FILES, if configured) directory.
+   This is usually the tracker's "html" directory. Raises a SendStaticFile
+   exception.
+c. if there is something in the path (as in example 1, "issue"), it
+   identifies the tracker class we're to display.
+d. if the path is an item designator (as in examples 2 and 4, "issue1"
+   and "file1"), then we're to display a specific item.
+e. if the path starts with an item designator and is longer than one
+   entry (as in example 5, "file1/kitten.png"), then we're assumed to be
+   handling an item of a ``FileClass``, and the extra path information
+   gives the filename that the client is going to label the download
+   with (i.e. "file1/kitten.png" is nicer to download than "file1").
+   This raises a ``SendFile`` exception.
+
+Both b. and e. stop before we bother to determine the template we're
+going to use. That's because they don't actually use templates.
+
+The template used is specified by the ``@template`` CGI variable, which
+defaults to:
+
+- only classname suplied:        "index"
+- full item designator supplied: "item"
+
+
+The "home" Context
+------------------
+
+The "home" context is special because it allows you to add templated
+pages to your tracker that don't rely on a class or item (ie. an issues
+list or specific issue).
+
+Let's say you wish to add frames to control the layout of your tracker's
+interface. You'd probably have:
+
+- A top-level frameset page. This page probably wouldn't be templated, so
+  it could be served as a static file (see `serving static content`_)
+- A sidebar frame that is templated. Let's call this page
+  "home.navigation.html" in your tracker's "html" directory. To load that
+  page up, you use the URL:
+
+    <tracker url>/home?@template=navigation
+
+
+Serving static content
+----------------------
+
+See the previous section `determining web context`_ where it describes
+``@@file`` paths.
+
+
+Performing actions in web requests
+----------------------------------
+
+When a user requests a web page, they may optionally also request for an
+action to take place. As described in `how requests are processed`_, the
+action is performed before the requested page is generated. Actions are
+triggered by using a ``@action`` CGI variable, where the value is one
+of:
+
+**login**
+ Attempt to log a user in.
+
+**logout**
+ Log the user out - make them "anonymous".
+
+**register**
+ Attempt to create a new user based on the contents of the form and then
+ log them in.
+
+**edit**
+ Perform an edit of an item in the database. There are some `special form
+ variables`_ you may use.
+
+**new**
+ Add a new item to the database. You may use the same `special form
+ variables`_ as in the "edit" action.
+
+**retire**
+ Retire the item in the database.
+
+**editCSV**
+ Performs an edit of all of a class' items in one go. See also the
+ *class*.csv templating method which generates the CSV data to be
+ edited, and the ``'_generic.index'`` template which uses both of these
+ features.
+
+**search**
+ Mangle some of the form variables:
+
+ - Set the form ":filter" variable based on the values of the filter
+   variables - if they're set to anything other than "dontcare" then add
+   them to :filter.
+
+ - Also handle the ":queryname" variable and save off the query to the
+   user's query list.
+
+Each of the actions is implemented by a corresponding ``*XxxAction*`` (where
+"Xxx" is the name of the action) class in the ``roundup.cgi.actions`` module.
+These classes are registered with ``roundup.cgi.client.Client``. If you need
+to define new actions, you may add them there (see `defining new
+web actions`_).
+
+Each action class also has a ``*permission*`` method which determines whether
+the action is permissible given the current user. The base permission checks
+for each action are:
+
+**login**
+ Determine whether the user has the "Web Access" Permission.
+**logout**
+ No permission checks are made.
+**register**
+ Determine whether the user has the ("Create", "user") Permission.
+**edit**
+ Determine whether the user has permission to edit this item. If we're
+ editing the "user" class, users are allowed to edit their own details -
+ unless they try to edit the "roles" property, which requires the
+ special Permission "Web Roles".
+**new**
+ Determine whether the user has permission to create this item. No
+ additional property checks are made. Additionally, new user items may
+ be created if the user has the ("Create", "user") Permission.
+**editCSV**
+ Determine whether the user has permission to edit this class.
+**search**
+ Determine whether the user has permission to view this class.
+
+
+Special form variables
+----------------------
+
+Item properties and their values are edited with html FORM
+variables and their values. You can:
+
+- Change the value of some property of the current item.
+- Create a new item of any class, and edit the new item's
+  properties,
+- Attach newly created items to a multilink property of the
+  current item.
+- Remove items from a multilink property of the current item.
+- Specify that some properties are required for the edit
+  operation to be successful.
+- Set up user interface locale.
+
+These operations will only take place if the form action (the
+``@action`` variable) is "edit" or "new".
+
+In the following, <bracketed> values are variable, "@" may be
+either ":" or "@", and other text "required" is fixed.
+
+Two special form variables are used to specify user language preferences:
+
+``@language``
+  value may be locale name or ``none``. If this variable is set to
+  locale name, web interface language is changed to given value
+  (provided that appropriate translation is available), the value
+  is stored in the browser cookie and will be used for all following
+  requests.  If value is ``none`` the cookie is removed and the
+  language is changed to the tracker default, set up in the tracker
+  configuration or OS environment.
+
+``@charset``
+  value may be character set name or ``none``.  Character set name
+  is stored in the browser cookie and sets output encoding for all
+  HTML pages generated by Roundup.  If value is ``none`` the cookie
+  is removed and HTML output is reset to Roundup internal encoding
+  (UTF-8).
+
+Most properties are specified as form variables:
+
+``<propname>``
+  property on the current context item
+
+``<designator>"@"<propname>``
+  property on the indicated item (for editing related information)
+
+Designators name a specific item of a class.
+
+``<classname><N>``
+    Name an existing item of class <classname>.
+
+``<classname>"-"<N>``
+    Name the <N>th new item of class <classname>. If the form
+    submission is successful, a new item of <classname> is
+    created. Within the submitted form, a particular
+    designator of this form always refers to the same new
+    item.
+
+Once we have determined the "propname", we look at it to see
+if it's special:
+
+``@required``
+    The associated form value is a comma-separated list of
+    property names that must be specified when the form is
+    submitted for the edit operation to succeed.  
+
+    When the <designator> is missing, the properties are
+    for the current context item.  When <designator> is
+    present, they are for the item specified by
+    <designator>.
+
+    The "@required" specifier must come before any of the
+    properties it refers to are assigned in the form.
+
+``@remove@<propname>=id(s)`` or ``@add@<propname>=id(s)``
+    The "@add@" and "@remove@" edit actions apply only to
+    Multilink properties.  The form value must be a
+    comma-separate list of keys for the class specified by
+    the simple form variable.  The listed items are added
+    to (respectively, removed from) the specified
+    property.
+
+``@link@<propname>=<designator>``
+    If the edit action is "@link@", the simple form
+    variable must specify a Link or Multilink property.
+    The form value is a comma-separated list of
+    designators.  The item corresponding to each
+    designator is linked to the property given by simple
+    form variable.
+
+None of the above (ie. just a simple form value)
+    The value of the form variable is converted
+    appropriately, depending on the type of the property.
+
+    For a Link('klass') property, the form value is a
+    single key for 'klass', where the key field is
+    specified in schema.py.  
+
+    For a Multilink('klass') property, the form value is a
+    comma-separated list of keys for 'klass', where the
+    key field is specified in schema.py.  
+
+    Note that for simple-form-variables specifiying Link
+    and Multilink properties, the linked-to class must
+    have a key field.
+
+    For a String() property specifying a filename, the
+    file named by the form value is uploaded. This means we
+    try to set additional properties "filename" and "type" (if
+    they are valid for the class).  Otherwise, the property
+    is set to the form value.
+
+    For Date(), Interval(), Boolean(), and Number()
+    properties, the form value is converted to the
+    appropriate
+
+Any of the form variables may be prefixed with a classname or
+designator.
+
+Two special form values are supported for backwards compatibility:
+
+ at note
+    This is equivalent to::
+
+        @link at messages=msg-1
+        msg-1 at content=value
+
+    except that in addition, the "author" and "date" properties of
+    "msg-1" are set to the userid of the submitter, and the current
+    time, respectively.
+
+ at file
+    This is equivalent to::
+
+        @link at files=file-1
+        file-1 at content=value
+
+    The String content value is handled as described above for file
+    uploads.
+
+If both the "@note" and "@file" form variables are
+specified, the action::
+
+        @link at msg-1@files=file-1
+
+is also performed.
+
+We also check that FileClass items have a "content" property with
+actual content, otherwise we remove them from all_props before
+returning.
+
+
+Default templates
+-----------------
+
+The default templates are html4 compliant. If you wish to change them to be
+xhtml compliant, you'll need to change the ``html_version`` configuration
+variable in ``config.ini`` to ``'xhtml'`` instead of ``'html4'``.
+
+Most customisation of the web view can be done by modifying the
+templates in the tracker ``'html'`` directory. There are several types
+of files in there. The *minimal* template includes:
+
+**page.html**
+  This template usually defines the overall look of your tracker. When
+  you view an issue, it appears inside this template. When you view an
+  index, it also appears inside this template. This template defines a
+  macro called "icing" which is used by almost all other templates as a
+  coating for their content, using its "content" slot. It also defines
+  the "head_title" and "body_title" slots to allow setting of the page
+  title.
+**home.html**
+  the default page displayed when no other page is indicated by the user
+**home.classlist.html**
+  a special version of the default page that lists the classes in the
+  tracker
+**classname.item.html**
+  displays an item of the *classname* class
+**classname.index.html**
+  displays a list of *classname* items
+**classname.search.html**
+  displays a search page for *classname* items
+**_generic.index.html**
+  used to display a list of items where there is no
+  ``*classname*.index`` available
+**_generic.help.html**
+  used to display a "class help" page where there is no
+  ``*classname*.help``
+**user.register.html**
+  a special page just for the user class, that renders the registration
+  page
+**style.css.html**
+  a static file that is served up as-is
+
+The *classic* template has a number of additional templates.
+
+Remember that you can create any template extension you want to,
+so if you just want to play around with the templating for new issues,
+you can copy the current "issue.item" template to "issue.test", and then
+access the test template using the "@template" URL argument::
+
+   http://your.tracker.example/tracker/issue?@template=test
+
+and it won't affect your users using the "issue.item" template.
+
+
+How the templates work
+----------------------
+
+
+Basic Templating Actions
+~~~~~~~~~~~~~~~~~~~~~~~~
+
+Roundup's templates consist of special attributes on the HTML tags.
+These attributes form the `Template Attribute Language`_, or TAL.
+The basic TAL commands are:
+
+**tal:define="variable expression; variable expression; ..."**
+   Define a new variable that is local to this tag and its contents. For
+   example::
+
+      <html tal:define="title request/description">
+       <head><title tal:content="title"></title></head>
+      </html>
+
+   In this example, the variable "title" is defined as the result of the
+   expression "request/description". The "tal:content" command inside the
+   <html> tag may then use the "title" variable.
+
+**tal:condition="expression"**
+   Only keep this tag and its contents if the expression is true. For
+   example::
+
+     <p tal:condition="python:request.user.hasPermission('View', 'issue')">
+      Display some issue information.
+     </p>
+
+   In the example, the <p> tag and its contents are only displayed if
+   the user has the "View" permission for issues. We consider the number
+   zero, a blank string, an empty list, and the built-in variable
+   nothing to be false values. Nearly every other value is true,
+   including non-zero numbers, and strings with anything in them (even
+   spaces!).
+
+**tal:repeat="variable expression"**
+   Repeat this tag and its contents for each element of the sequence
+   that the expression returns, defining a new local variable and a
+   special "repeat" variable for each element. For example::
+
+     <tr tal:repeat="u user/list">
+      <td tal:content="u/id"></td>
+      <td tal:content="u/username"></td>
+      <td tal:content="u/realname"></td>
+     </tr>
+
+   The example would iterate over the sequence of users returned by
+   "user/list" and define the local variable "u" for each entry. Using
+   the repeat command creates a new variable called "repeat" which you
+   may access to gather information about the iteration. See the section
+   below on `the repeat variable`_.
+
+**tal:replace="expression"**
+   Replace this tag with the result of the expression. For example::
+
+    <span tal:replace="request/user/realname" />
+
+   The example would replace the <span> tag and its contents with the
+   user's realname. If the user's realname was "Bruce", then the
+   resultant output would be "Bruce".
+
+**tal:content="expression"**
+   Replace the contents of this tag with the result of the expression.
+   For example::
+
+    <span tal:content="request/user/realname">user's name appears here
+    </span>
+
+   The example would replace the contents of the <span> tag with the
+   user's realname. If the user's realname was "Bruce" then the
+   resultant output would be "<span>Bruce</span>".
+
+**tal:attributes="attribute expression; attribute expression; ..."**
+   Set attributes on this tag to the results of expressions. For
+   example::
+
+     <a tal:attributes="href string:user${request/user/id}">My Details</a>
+
+   In the example, the "href" attribute of the <a> tag is set to the
+   value of the "string:user${request/user/id}" expression, which will
+   be something like "user123".
+
+**tal:omit-tag="expression"**
+   Remove this tag (but not its contents) if the expression is true. For
+   example::
+
+      <span tal:omit-tag="python:1">Hello, world!</span>
+
+   would result in output of::
+
+      Hello, world!
+
+Note that the commands on a given tag are evaulated in the order above,
+so *define* comes before *condition*, and so on.
+
+Additionally, you may include tags such as <tal:block>, which are
+removed from output. Its content is kept, but the tag itself is not (so
+don't go using any "tal:attributes" commands on it). This is useful for
+making arbitrary blocks of HTML conditional or repeatable (very handy
+for repeating multiple table rows, which would othewise require an
+illegal tag placement to effect the repeat).
+
+.. _TAL:
+.. _Template Attribute Language:
+   http://dev.zope.org/Wikis/DevSite/Projects/ZPT/TAL%20Specification%201.4
+
+
+Templating Expressions
+~~~~~~~~~~~~~~~~~~~~~~
+
+Templating Expressions are covered by `Template Attribute Language
+Expression Syntax`_, or TALES. The expressions you may use in the
+attribute values may be one of the following forms:
+
+**Path Expressions** - eg. ``item/status/checklist``
+   These are object attribute / item accesses. Roughly speaking, the
+   path ``item/status/checklist`` is broken into parts ``item``,
+   ``status`` and ``checklist``. The ``item`` part is the root of the
+   expression. We then look for a ``status`` attribute on ``item``, or
+   failing that, a ``status`` item (as in ``item['status']``). If that
+   fails, the path expression fails. When we get to the end, the object
+   we're left with is evaluated to get a string - if it is a method, it
+   is called; if it is an object, it is stringified. Path expressions
+   may have an optional ``path:`` prefix, but they are the default
+   expression type, so it's not necessary.
+
+   If an expression evaluates to ``default``, then the expression is
+   "cancelled" - whatever HTML already exists in the template will
+   remain (tag content in the case of ``tal:content``, attributes in the
+   case of ``tal:attributes``).
+
+   If an expression evaluates to ``nothing`` then the target of the
+   expression is removed (tag content in the case of ``tal:content``,
+   attributes in the case of ``tal:attributes`` and the tag itself in
+   the case of ``tal:replace``).
+
+   If an element in the path may not exist, then you can use the ``|``
+   operator in the expression to provide an alternative. So, the
+   expression ``request/form/foo/value | default`` would simply leave
+   the current HTML in place if the "foo" form variable doesn't exist.
+
+   You may use the python function ``path``, as in
+   ``path("item/status")``, to embed path expressions in Python
+   expressions.
+
+**String Expressions** - eg. ``string:hello ${user/name}`` 
+   These expressions are simple string interpolations - though they can
+   be just plain strings with no interpolation if you want. The
+   expression in the ``${ ... }`` is just a path expression as above.
+
+**Python Expressions** - eg. ``python: 1+1`` 
+   These expressions give the full power of Python. All the "root level"
+   variables are available, so ``python:item.status.checklist()`` would
+   be equivalent to ``item/status/checklist``, assuming that
+   ``checklist`` is a method.
+
+Modifiers:
+
+**structure** - eg. ``structure python:msg.content.plain(hyperlink=1)``
+   The result of expressions are normally *escaped* to be safe for HTML
+   display (all "<", ">" and "&" are turned into special entities). The
+   ``structure`` expression modifier turns off this escaping - the
+   result of the expression is now assumed to be HTML, which is passed
+   to the web browser for rendering.
+
+**not:** - eg. ``not:python:1=1``
+   This simply inverts the logical true/false value of another
+   expression.
+
+.. _TALES:
+.. _Template Attribute Language Expression Syntax:
+   http://dev.zope.org/Wikis/DevSite/Projects/ZPT/TALES%20Specification%201.3
+
+
+Template Macros
+~~~~~~~~~~~~~~~
+
+Macros are used in Roundup to save us from repeating the same common
+page stuctures over and over. The most common (and probably only) macro
+you'll use is the "icing" macro defined in the "page" template.
+
+Macros are generated and used inside your templates using special
+attributes similar to the `basic templating actions`_. In this case,
+though, the attributes belong to the `Macro Expansion Template
+Attribute Language`_, or METAL. The macro commands are:
+
+**metal:define-macro="macro name"**
+  Define that the tag and its contents are now a macro that may be
+  inserted into other templates using the *use-macro* command. For
+  example::
+
+    <html metal:define-macro="page">
+     ...
+    </html>
+
+  defines a macro called "page" using the ``<html>`` tag and its
+  contents. Once defined, macros are stored on the template they're
+  defined on in the ``macros`` attribute. You can access them later on
+  through the ``templates`` variable, eg. the most common
+  ``templates/page/macros/icing`` to access the "page" macro of the
+  "page" template.
+
+**metal:use-macro="path expression"**
+  Use a macro, which is identified by the path expression (see above).
+  This will replace the current tag with the identified macro contents.
+  For example::
+
+   <tal:block metal:use-macro="templates/page/macros/icing">
+    ...
+   </tal:block>
+
+   will replace the tag and its contents with the "page" macro of the
+   "page" template.
+
+**metal:define-slot="slot name"** and **metal:fill-slot="slot name"**
+  To define *dynamic* parts of the macro, you define "slots" which may
+  be filled when the macro is used with a *use-macro* command. For
+  example, the ``templates/page/macros/icing`` macro defines a slot like
+  so::
+
+    <title metal:define-slot="head_title">title goes here</title>
+
+  In your *use-macro* command, you may now use a *fill-slot* command
+  like this::
+
+    <title metal:fill-slot="head_title">My Title</title>
+
+  where the tag that fills the slot completely replaces the one defined
+  as the slot in the macro.
+
+Note that you may not mix `METAL`_ and `TAL`_ commands on the same tag, but
+TAL commands may be used freely inside METAL-using tags (so your
+*fill-slots* tags may have all manner of TAL inside them).
+
+.. _METAL:
+.. _Macro Expansion Template Attribute Language:
+   http://dev.zope.org/Wikis/DevSite/Projects/ZPT/METAL%20Specification%201.0
+
+Information available to templates
+----------------------------------
+
+This is implemented by ``roundup.cgi.templating.RoundupPageTemplate``
+
+The following variables are available to templates.
+
+**context**
+  The current context. This is either None, a `hyperdb class wrapper`_
+  or a `hyperdb item wrapper`_
+**request**
+  Includes information about the current request, including:
+   - the current index information (``filterspec``, ``filter`` args,
+     ``properties``, etc) parsed out of the form. 
+   - methods for easy filterspec link generation
+   - *user*, the current user item as an HTMLItem instance
+   - *form*
+     The current CGI form information as a mapping of form argument name
+     to value
+**config**
+  This variable holds all the values defined in the tracker config.ini
+  file (eg. TRACKER_NAME, etc.)
+**db**
+  The current database, used to access arbitrary database items.
+**templates**
+  Access to all the tracker templates by name. Used mainly in
+  *use-macro* commands.
+**utils**
+  This variable makes available some utility functions like batching.
+**nothing**
+  This is a special variable - if an expression evaluates to this, then
+  the tag (in the case of a ``tal:replace``), its contents (in the case
+  of ``tal:content``) or some attributes (in the case of
+  ``tal:attributes``) will not appear in the the output. So, for
+  example::
+
+    <span tal:attributes="class nothing">Hello, World!</span>
+
+  would result in::
+
+    <span>Hello, World!</span>
+
+**default**
+  Also a special variable - if an expression evaluates to this, then the
+  existing HTML in the template will not be replaced or removed, it will
+  remain. So::
+
+    <span tal:replace="default">Hello, World!</span>
+
+  would result in::
+
+    <span>Hello, World!</span>
+
+**true**, **false**
+  Boolean constants that may be used in `templating expressions`_
+  instead of ``python:1`` and ``python:0``.
+**i18n**
+  Internationalization service, providing two string translation methods:
+
+  **gettext** (*message*)
+    Return the localized translation of message
+  **ngettext** (*singular*, *plural*, *number*)
+    Like ``gettext()``, but consider plural forms. If a translation
+    is found, apply the plural formula to *number*, and return the
+    resulting message (some languages have more than two plural forms).
+    If no translation is found, return singular if *number* is 1;
+    return plural otherwise.
+
+    This function requires python2.3; in earlier python versions
+    may not work as expected.
+
+The context variable
+~~~~~~~~~~~~~~~~~~~~
+
+The *context* variable is one of three things based on the current
+context (see `determining web context`_ for how we figure this out):
+
+1. if we're looking at a "home" page, then it's None
+2. if we're looking at a specific hyperdb class, it's a
+   `hyperdb class wrapper`_.
+3. if we're looking at a specific hyperdb item, it's a
+   `hyperdb item wrapper`_.
+
+If the context is not None, we can access the properties of the class or
+item. The only real difference between cases 2 and 3 above are:
+
+1. the properties may have a real value behind them, and this will
+   appear if the property is displayed through ``context/property`` or
+   ``context/property/field``.
+2. the context's "id" property will be a false value in the second case,
+   but a real, or true value in the third. Thus we can determine whether
+   we're looking at a real item from the hyperdb by testing
+   "context/id".
+
+Hyperdb class wrapper
+:::::::::::::::::::::
+
+This is implemented by the ``roundup.cgi.templating.HTMLClass``
+class.
+
+This wrapper object provides access to a hyperb class. It is used
+primarily in both index view and new item views, but it's also usable
+anywhere else that you wish to access information about a class, or the
+items of a class, when you don't have a specific item of that class in
+mind.
+
+We allow access to properties. There will be no "id" property. The value
+accessed through the property will be the current value of the same name
+from the CGI form.
+
+There are several methods available on these wrapper objects:
+
+=========== =============================================================
+Method      Description
+=========== =============================================================
+properties  return a `hyperdb property wrapper`_ for all of this class's
+            properties.
+list        lists all of the active (not retired) items in the class.
+csv         return the items of this class as a chunk of CSV text.
+propnames   lists the names of the properties of this class.
+filter      lists of items from this class, filtered and sorted. Two
+            options are avaible for sorting:
+
+            1. by the current *request* filterspec/filter/sort/group args
+            2. by the "filterspec", "sort" and "group" keyword args.
+               "filterspec" is ``{propname: value(s)}``. "sort" and
+               "group" are ``(dir, prop)`` where dir is '+', '-' or None
+               and prop is a prop name or None.
+
+            eg. ``issue.filter(filterspec={"priority": "1"},
+            sort=('activity', '+'))``
+
+classhelp   display a link to a javascript popup containing this class'
+            "help" template.
+
+            This generates a link to a popup window which displays the
+            properties indicated by "properties" of the class named by
+            "classname". The "properties" should be a comma-separated list
+            (eg. 'id,name,description'). Properties defaults to all the
+            properties of a class (excluding id, creator, created and
+            activity).
+
+            You may optionally override the "label" displayed, the "width",
+            the "height", the number of items per page ("pagesize") and
+            the field on which the list is sorted ("sort").
+
+            With the "filter" arg it is possible to specify a filter for
+            which items are supposed to be displayed. It has to be of
+            the format "<field>=<values>;<field>=<values>;...".
+
+            The popup window will be resizable and scrollable.
+
+            If the "property" arg is given, it's passed through to the
+            javascript help_window function. This allows updating of a
+            property in the calling HTML page.
+
+            If the "form" arg is given, it's passed through to the
+            javascript help_window function - it's the name of the form
+            the "property" belongs to.
+
+submit      generate a submit button (and action hidden element)
+renderWith  render this class with the given template.
+history     returns 'New node - no history' :)
+is_edit_ok  is the user allowed to Edit the current class?
+is_view_ok  is the user allowed to View the current class?
+=========== =============================================================
+
+Note that if you have a property of the same name as one of the above
+methods, you'll need to access it using a python "item access"
+expression. For example::
+
+   python:context['list']
+
+will access the "list" property, rather than the list method.
+
+
+Hyperdb item wrapper
+::::::::::::::::::::
+
+This is implemented by the ``roundup.cgi.templating.HTMLItem``
+class.
+
+This wrapper object provides access to a hyperb item.
+
+We allow access to properties. There will be no "id" property. The value
+accessed through the property will be the current value of the same name
+from the CGI form.
+
+There are several methods available on these wrapper objects:
+
+=============== ========================================================
+Method          Description
+=============== ========================================================
+submit          generate a submit button (and action hidden element)
+journal         return the journal of the current item (**not
+                implemented**)
+history         render the journal of the current item as HTML
+renderQueryForm specific to the "query" class - render the search form
+                for the query
+hasPermission   specific to the "user" class - determine whether the
+                user has a Permission. The signature is::
+
+                    hasPermission(self, permission, [classname=],
+                        [property=], [itemid=])
+
+                where the classname defaults to the current context.
+hasRole         specific to the "user" class - determine whether the
+                user has a Role. The signature is::
+
+                    hasRole(self, rolename)
+
+is_edit_ok      is the user allowed to Edit the current item?
+is_view_ok      is the user allowed to View the current item?
+is_retired      is the item retired?
+download_url    generate a url-quoted link for download of FileClass
+                item contents (ie. file<id>/<name>)
+copy_url        generate a url-quoted link for creating a copy
+                of this item.  By default, the copy will acquire
+                all properties of the current item except for
+                ``messages`` and ``files``.  This can be overridden
+                by passing ``exclude`` argument which contains a list
+                (or any iterable) of property names that shall not be
+                copied.  Database-driven properties like ``id`` or
+                ``activity`` cannot be copied.
+=============== ========================================================
+
+Note that if you have a property of the same name as one of the above
+methods, you'll need to access it using a python "item access"
+expression. For example::
+
+   python:context['journal']
+
+will access the "journal" property, rather than the journal method.
+
+
+Hyperdb property wrapper
+::::::::::::::::::::::::
+
+This is implemented by subclasses of the
+``roundup.cgi.templating.HTMLProperty`` class (``HTMLStringProperty``,
+``HTMLNumberProperty``, and so on).
+
+This wrapper object provides access to a single property of a class. Its
+value may be either:
+
+1. if accessed through a `hyperdb item wrapper`_, then it's a value from
+   the hyperdb
+2. if access through a `hyperdb class wrapper`_, then it's a value from
+   the CGI form
+
+
+The property wrapper has some useful attributes:
+
+=============== ========================================================
+Attribute       Description
+=============== ========================================================
+_name           the name of the property
+_value          the value of the property if any - this is the actual
+                value retrieved from the hyperdb for this property
+=============== ========================================================
+
+There are several methods available on these wrapper objects:
+
+=========== ================================================================
+Method      Description
+=========== ================================================================
+plain       render a "plain" representation of the property. This method
+            may take two arguments:
+
+            escape
+             If true, escape the text so it is HTML safe (default: no). The
+             reason this defaults to off is that text is usually escaped
+             at a later stage by the TAL commands, unless the "structure"
+             option is used in the template. The following ``tal:content``
+             expressions are all equivalent::
+ 
+              "structure python:msg.content.plain(escape=1)"
+              "python:msg.content.plain()"
+              "msg/content/plain"
+              "msg/content"
+
+             Usually you'll only want to use the escape option in a
+             complex expression.
+
+            hyperlink
+             If true, turn URLs, email addresses and hyperdb item
+             designators in the text into hyperlinks (default: no). Note
+             that you'll need to use the "structure" TAL option if you
+             want to use this ``tal:content`` expression::
+  
+              "structure python:msg.content.plain(hyperlink=1)"
+
+             The text is automatically HTML-escaped before the hyperlinking
+             transformation done in the plain() method.
+
+hyperlinked The same as msg.content.plain(hyperlink=1), but nicer::
+
+              "structure msg/content/hyperlinked"
+
+field       render an appropriate form edit field for the property - for
+            most types this is a text entry box, but for Booleans it's a
+            tri-state yes/no/neither selection. This method may take some
+            arguments:
+
+            size
+              Sets the width in characters of the edit field
+
+            format (Date properties only)
+              Sets the format of the date in the field - uses the same
+              format string argument as supplied to the ``pretty`` method
+              below.
+
+stext       only on String properties - render the value of the property
+            as StructuredText (requires the StructureText module to be
+            installed separately)
+multiline   only on String properties - render a multiline form edit
+            field for the property
+email       only on String properties - render the value of the property
+            as an obscured email address
+confirm     only on Password properties - render a second form edit field
+            for the property, used for confirmation that the user typed
+            the password correctly. Generates a field with name
+            "name:confirm".
+now         only on Date properties - return the current date as a new
+            property
+reldate     only on Date properties - render the interval between the date
+            and now
+local       only on Date properties - return this date as a new property
+            with some timezone offset, for example::
+            
+                python:context.creation.local(10)
+
+            will render the date with a +10 hour offset.
+pretty      Date properties - render the date as "dd Mon YYYY" (eg. "19
+            Mar 2004"). Takes an optional format argument, for example::
+
+                python:context.activity.pretty('%Y-%m-%d')
+
+            Will format as "2004-03-19" instead.
+
+            Interval properties - render the interval in a pretty
+            format (eg. "yesterday"). The format arguments are those used
+            in the standard ``strftime`` call (see the `Python Library
+            Reference: time module`__)
+popcal      Generate a link to a popup calendar which may be used to
+            edit the date field, for example::
+
+              <span tal:replace="structure context/due/popcal" />
+
+menu        only on Link and Multilink properties - render a form select
+            list for this property. Takes a number of optional arguments
+
+            size
+               is used to limit the length of the list labels
+            height
+               is used to set the <select> tag's "size" attribute
+            showid
+               includes the item ids in the list labels
+            additional
+               lists properties which should be included in the label
+            sort_on
+                indicates the property to sort the list on as (direction,
+                (direction, property) where direction is '+' or '-'. A
+                single string with the direction prepended may be used.
+                For example: ('-', 'order'), '+name'.
+            value
+                gives a default value to preselect in the menu
+
+            The remaining keyword arguments are used as conditions for
+            filtering the items in the list - they're passed as the
+            "filterspec" argument to a Class.filter() call. For example::
+
+             <span tal:replace="structure context/status/menu" />
+
+             <span tal:replace="python:context.status.menu(order='+name",
+                                   value='chatting', 
+                                   filterspec={'status': '1,2,3,4'}" />
+
+sorted      only on Multilink properties - produce a list of the linked
+            items sorted by some property, for example::
+            
+                python:context.files.sorted('creation')
+
+            Will list the files by upload date.
+reverse     only on Multilink properties - produce a list of the linked
+            items in reverse order
+isset       returns True if the property has been set to a value
+=========== ================================================================
+
+__ http://docs.python.org/lib/module-time.html
+
+All of the above functions perform checks for permissions required to
+display or edit the data they are manipulating. The simplest case is
+editing an issue title. Including the expression::
+
+   context/title/field
+
+Will present the user with an edit field, if they have edit permission. If
+not, then they will be presented with a static display if they have view
+permission. If they don't even have view permission, then an error message
+is raised, preventing the display of the page, indicating that they don't
+have permission to view the information.
+
+
+The request variable
+~~~~~~~~~~~~~~~~~~~~
+
+This is implemented by the ``roundup.cgi.templating.HTMLRequest``
+class.
+
+The request variable is packed with information about the current
+request.
+
+.. taken from ``roundup.cgi.templating.HTMLRequest`` docstring
+
+=========== ============================================================
+Variable    Holds
+=========== ============================================================
+form        the CGI form as a cgi.FieldStorage
+env         the CGI environment variables
+base        the base URL for this tracker
+user        a HTMLUser instance for this user
+classname   the current classname (possibly None)
+template    the current template (suffix, also possibly None)
+form        the current CGI form variables in a FieldStorage
+=========== ============================================================
+
+**Index page specific variables (indexing arguments)**
+
+=========== ============================================================
+Variable    Holds
+=========== ============================================================
+columns     dictionary of the columns to display in an index page
+show        a convenience access to columns - request/show/colname will
+            be true if the columns should be displayed, false otherwise
+sort        index sort column (direction, column name)
+group       index grouping property (direction, column name)
+filter      properties to filter the index on
+filterspec  values to filter the index on
+search_text text to perform a full-text search on for an index
+=========== ============================================================
+
+There are several methods available on the request variable:
+
+=============== ========================================================
+Method          Description
+=============== ========================================================
+description     render a description of the request - handle for the
+                page title
+indexargs_form  render the current index args as form elements
+indexargs_url   render the current index args as a URL
+base_javascript render some javascript that is used by other components
+                of the templating
+batch           run the current index args through a filter and return a
+                list of items (see `hyperdb item wrapper`_, and
+                `batching`_)
+=============== ========================================================
+
+The form variable
+:::::::::::::::::
+
+The form variable is a bit special because it's actually a python
+FieldStorage object. That means that you have two ways to access its
+contents. For example, to look up the CGI form value for the variable
+"name", use the path expression::
+
+   request/form/name/value
+
+or the python expression::
+
+   python:request.form['name'].value
+
+Note the "item" access used in the python case, and also note the
+explicit "value" attribute we have to access. That's because the form
+variables are stored as MiniFieldStorages. If there's more than one
+"name" value in the form, then the above will break since
+``request/form/name`` is actually a *list* of MiniFieldStorages. So it's
+best to know beforehand what you're dealing with.
+
+
+The db variable
+~~~~~~~~~~~~~~~
+
+This is implemented by the ``roundup.cgi.templating.HTMLDatabase``
+class.
+
+Allows access to all hyperdb classes as attributes of this variable. If
+you want access to the "user" class, for example, you would use::
+
+  db/user
+  python:db.user
+
+Also, the current id of the current user is available as
+``db.getuid()``. This isn't so useful in templates (where you have
+``request/user``), but it can be useful in detectors or interfaces.
+
+The access results in a `hyperdb class wrapper`_.
+
+
+The templates variable
+~~~~~~~~~~~~~~~~~~~~~~
+
+This is implemented by the ``roundup.cgi.templating.Templates``
+class.
+
+This variable doesn't have any useful methods defined. It supports being
+used in expressions to access the templates, and consequently the
+template macros. You may access the templates using the following path
+expression::
+
+   templates/name
+
+or the python expression::
+
+   templates[name]
+
+where "name" is the name of the template you wish to access. The
+template has one useful attribute, namely "macros". To access a specific
+macro (called "macro_name"), use the path expression::
+
+   templates/name/macros/macro_name
+
+or the python expression::
+
+   templates[name].macros[macro_name]
+
+The repeat variable
+~~~~~~~~~~~~~~~~~~~
+
+The repeat variable holds an entry for each active iteration. That is, if
+you have a ``tal:repeat="user db/users"`` command, then there will be a
+repeat variable entry called "user". This may be accessed as either::
+
+    repeat/user
+    python:repeat['user']
+
+The "user" entry has a number of methods available for information:
+
+=============== =========================================================
+Method          Description
+=============== =========================================================
+first           True if the current item is the first in the sequence.
+last            True if the current item is the last in the sequence.
+even            True if the current item is an even item in the sequence.
+odd             True if the current item is an odd item in the sequence.
+number          Current position in the sequence, starting from 1.
+letter          Current position in the sequence as a letter, a through
+                z, then aa through zz, and so on.
+Letter          Same as letter(), except uppercase.
+roman           Current position in the sequence as lowercase roman
+                numerals.
+Roman           Same as roman(), except uppercase.
+=============== =========================================================
+
+
+The utils variable
+~~~~~~~~~~~~~~~~~~
+
+This is implemented by the
+``roundup.cgi.templating.TemplatingUtils`` class, but it may be extended
+as described below.
+
+=============== ========================================================
+Method          Description
+=============== ========================================================
+Batch           return a batch object using the supplied list
+url_quote       quote some text as safe for a URL (ie. space, %, ...)
+html_quote      quote some text as safe in HTML (ie. <, >, ...)
+html_calendar   renders an HTML calendar used by the
+                ``_generic.calendar.html`` template (itself invoked by
+                the popupCalendar DateHTMLProperty method
+=============== ========================================================
+
+You may add additional utility methods by writing them in your tracker
+``extensions`` directory and registering them with the templating system
+using ``instance.registerUtil`` (see `adding a time log to your issues`_ for
+an example of this).
+
+
+Batching
+::::::::
+
+Use Batch to turn a list of items, or item ids of a given class, into a
+series of batches. Its usage is::
+
+    python:utils.Batch(sequence, size, start, end=0, orphan=0,
+    overlap=0)
+
+or, to get the current index batch::
+
+    request/batch
+
+The parameters are:
+
+========= ==============================================================
+Parameter  Usage
+========= ==============================================================
+sequence  a list of HTMLItems
+size      how big to make the sequence.
+start     where to start (0-indexed) in the sequence.
+end       where to end (0-indexed) in the sequence.
+orphan    if the next batch would contain less items than this value,
+          then it is combined with this batch
+overlap   the number of items shared between adjacent batches
+========= ==============================================================
+
+All of the parameters are assigned as attributes on the batch object. In
+addition, it has several more attributes:
+
+=============== ========================================================
+Attribute       Description
+=============== ========================================================
+start           indicates the start index of the batch. *Unlike
+                the argument, is a 1-based index (I know, lame)*
+first           indicates the start index of the batch *as a 0-based
+                index*
+length          the actual number of elements in the batch
+sequence_length the length of the original, unbatched, sequence.
+=============== ========================================================
+
+And several methods:
+
+=============== ========================================================
+Method          Description
+=============== ========================================================
+previous        returns a new Batch with the previous batch settings
+next            returns a new Batch with the next batch settings
+propchanged     detect if the named property changed on the current item
+                when compared to the last item
+=============== ========================================================
+
+An example of batching::
+
+ <table class="otherinfo">
+  <tr><th colspan="4" class="header">Existing Keywords</th></tr>
+  <tr tal:define="keywords db/keyword/list"
+      tal:repeat="start python:range(0, len(keywords), 4)">
+   <td tal:define="batch python:utils.Batch(keywords, 4, start)"
+       tal:repeat="keyword batch" tal:content="keyword/name">
+       keyword here</td>
+  </tr>
+ </table>
+
+... which will produce a table with four columns containing the items of
+the "keyword" class (well, their "name" anyway).
+
+
+Displaying Properties
+---------------------
+
+Properties appear in the user interface in three contexts: in indices,
+in editors, and as search arguments. For each type of property, there
+are several display possibilities. For example, in an index view, a
+string property may just be printed as a plain string, but in an editor
+view, that property may be displayed in an editable field.
+
+
+Index Views
+-----------
+
+This is one of the class context views. It is also the default view for
+classes. The template used is "*classname*.index".
+
+
+Index View Specifiers
+~~~~~~~~~~~~~~~~~~~~~
+
+An index view specifier (URL fragment) looks like this (whitespace has
+been added for clarity)::
+
+    /issue?status=unread,in-progress,resolved&
+        topic=security,ui&
+        @group=priority&
+        @sort=-activity&
+        @filters=status,topic&
+        @columns=title,status,fixer
+
+The index view is determined by two parts of the specifier: the layout
+part and the filter part. The layout part consists of the query
+parameters that begin with colons, and it determines the way that the
+properties of selected items are displayed. The filter part consists of
+all the other query parameters, and it determines the criteria by which
+items are selected for display. The filter part is interactively
+manipulated with the form widgets displayed in the filter section. The
+layout part is interactively manipulated by clicking on the column
+headings in the table.
+
+The filter part selects the union of the sets of items with values
+matching any specified Link properties and the intersection of the sets
+of items with values matching any specified Multilink properties.
+
+The example specifies an index of "issue" items. Only items with a
+"status" of either "unread" or "in-progress" or "resolved" are
+displayed, and only items with "topic" values including both "security"
+and "ui" are displayed. The items are grouped by priority, arranged in
+ascending order; and within groups, sorted by activity, arranged in
+descending order. The filter section shows filters for the "status" and
+"topic" properties, and the table includes columns for the "title",
+"status", and "fixer" properties.
+
+============ =============================================================
+Argument     Description
+============ =============================================================
+ at sort        sort by prop name, optionally preceeded with '-' to give
+             descending or nothing for ascending sorting.
+ at group       group by prop name, optionally preceeded with '-' or to sort
+             in descending or nothing for ascending order.
+ at columns     selects the columns that should be displayed. Default is
+             all.                     
+ at filter      indicates which properties are being used in filtering.
+             Default is none.
+propname     selects the values the item properties given by propname must
+             have (very basic search/filter).
+ at search_text if supplied, performs a full-text search (message bodies,
+             issue titles, etc)
+============ =============================================================
+
+
+Searching Views
+---------------
+
+.. note::
+   if you add a new column to the ``@columns`` form variable potentials
+   then you will need to add the column to the appropriate `index views`_
+   template so that it is actually displayed.
+
+This is one of the class context views. The template used is typically
+"*classname*.search". The form on this page should have "search" as its
+``@action`` variable. The "search" action:
+
+- sets up additional filtering, as well as performing indexed text
+  searching
+- sets the ``@filter`` variable correctly
+- saves the query off if ``@query_name`` is set.
+
+The search page should lay out any fields that you wish to allow the
+user to search on. If your schema contains a large number of properties,
+you should be wary of making all of those properties available for
+searching, as this can cause confusion. If the additional properties are
+Strings, consider having their value indexed, and then they will be
+searchable using the full text indexed search. This is both faster, and
+more useful for the end user.
+
+If the search view does specify the "search" ``@action``, then it may also
+provide an additional argument:
+
+============ =============================================================
+Argument     Description
+============ =============================================================
+ at query_name  if supplied, the index parameters (including @search_text)
+             will be saved off as a the query item and registered against
+             the user's queries property. Note that the *classic* template
+             schema has this ability, but the *minimal* template schema
+             does not.
+============ =============================================================
+
+
+Item Views
+----------
+
+The basic view of a hyperdb item is provided by the "*classname*.item"
+template. It generally has three sections; an "editor", a "spool" and a
+"history" section.
+
+
+Editor Section
+~~~~~~~~~~~~~~
+
+The editor section is used to manipulate the item - it may be a static
+display if the user doesn't have permission to edit the item.
+
+Here's an example of a basic editor template (this is the default
+"classic" template issue item edit form - from the "issue.item.html"
+template)::
+
+ <table class="form">
+ <tr>
+  <th>Title</th>
+  <td colspan="3" tal:content="structure python:context.title.field(size=60)">title</td>
+ </tr>
+ 
+ <tr>
+  <th>Priority</th>
+  <td tal:content="structure context/priority/menu">priority</td>
+  <th>Status</th>
+  <td tal:content="structure context/status/menu">status</td>
+ </tr>
+ 
+ <tr>
+  <th>Superseder</th>
+  <td>
+   <span tal:replace="structure python:context.superseder.field(showid=1, size=20)" />
+   <span tal:replace="structure python:db.issue.classhelp('id,title')" />
+   <span tal:condition="context/superseder">
+    <br>View: <span tal:replace="structure python:context.superseder.link(showid=1)" />
+   </span>
+  </td>
+  <th>Nosy List</th>
+  <td>
+   <span tal:replace="structure context/nosy/field" />
+   <span tal:replace="structure python:db.user.classhelp('username,realname,address,phone')" />
+  </td>
+ </tr>
+ 
+ <tr>
+  <th>Assigned To</th>
+  <td tal:content="structure context/assignedto/menu">
+   assignedto menu
+  </td>
+  <td>&nbsp;</td>
+  <td>&nbsp;</td>
+ </tr>
+ 
+ <tr>
+  <th>Change Note</th>
+  <td colspan="3">
+   <textarea name=":note" wrap="hard" rows="5" cols="60"></textarea>
+  </td>
+ </tr>
+ 
+ <tr>
+  <th>File</th>
+  <td colspan="3"><input type="file" name=":file" size="40"></td>
+ </tr>
+ 
+ <tr>
+  <td>&nbsp;</td>
+  <td colspan="3" tal:content="structure context/submit">
+   submit button will go here
+  </td>
+ </tr>
+ </table>
+
+
+When a change is submitted, the system automatically generates a message
+describing the changed properties. As shown in the example, the editor
+template can use the ":note" and ":file" fields, which are added to the
+standard changenote message generated by Roundup.
+
+
+Form values
+:::::::::::
+
+We have a number of ways to pull properties out of the form in order to
+meet the various needs of:
+
+1. editing the current item (perhaps an issue item)
+2. editing information related to the current item (eg. messages or
+   attached files)
+3. creating new information to be linked to the current item (eg. time
+   spent on an issue)
+
+In the following, ``<bracketed>`` values are variable, ":" may be one of
+":" or "@", and other text ("required") is fixed.
+
+Properties are specified as form variables:
+
+``<propname>``
+  property on the current context item
+
+``<designator>:<propname>``
+  property on the indicated item (for editing related information)
+
+``<classname>-<N>:<propname>``
+  property on the Nth new item of classname (generally for creating new
+  items to attach to the current item)
+
+Once we have determined the "propname", we check to see if it is one of
+the special form values:
+
+``@required``
+  The named property values must be supplied or a ValueError will be
+  raised.
+
+``@remove@<propname>=id(s)``
+  The ids will be removed from the multilink property.
+
+``:add:<propname>=id(s)``
+  The ids will be added to the multilink property.
+
+``:link:<propname>=<designator>``
+  Used to add a link to new items created during edit. These are
+  collected and returned in ``all_links``. This will result in an
+  additional linking operation (either Link set or Multilink append)
+  after the edit/create is done using ``all_props`` in ``_editnodes``.
+  The <propname> on the current item will be set/appended the id of the
+  newly created item of class <designator> (where <designator> must be
+  <classname>-<N>).
+
+Any of the form variables may be prefixed with a classname or
+designator.
+
+Two special form values are supported for backwards compatibility:
+
+``:note``
+  create a message (with content, author and date), linked to the
+  context item. This is ALWAYS designated "msg-1".
+``:file``
+  create a file, attached to the current item and any message created by
+  :note. This is ALWAYS designated "file-1".
+
+
+Spool Section
+~~~~~~~~~~~~~
+
+The spool section lists related information like the messages and files
+of an issue.
+
+TODO
+
+
+History Section
+~~~~~~~~~~~~~~~
+
+The final section displayed is the history of the item - its database
+journal. This is generally generated with the template::
+
+ <tal:block tal:replace="structure context/history" />
+
+*To be done:*
+
+*The actual history entries of the item may be accessed for manual
+templating through the "journal" method of the item*::
+
+ <tal:block tal:repeat="entry context/journal">
+  a journal entry
+ </tal:block>
+
+*where each journal entry is an HTMLJournalEntry.*
+
+
+Defining new web actions
+------------------------
+
+You may define new actions to be triggered by the ``@action`` form variable.
+These are added to the tracker ``extensions`` directory and registered
+using ``instance.registerAction``.
+
+All the existing Actions are defined in ``roundup.cgi.actions``.
+
+Adding action classes takes three steps; first you `define the new
+action class`_, then you `register the action class`_ with the cgi
+interface so it may be triggered by the ``@action`` form variable.
+Finally you `use the new action`_ in your HTML form.
+
+See "`setting up a "wizard" (or "druid") for controlled adding of
+issues`_" for an example.
+
+
+Define the new action class
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Create a new action class in your tracker's ``extensions`` directory, for
+example ``myaction.py``::
+
+ from roundup.cgi.actions import Action
+
+ class MyAction(Action):
+     def handle(self):
+         ''' Perform some action. No return value is required.
+         '''
+
+The *self.client* attribute is an instance of ``roundup.cgi.client.Client``.
+See the docstring of that class for details of what it can do.
+
+The method will typically check the ``self.form`` variable's contents.
+It may then:
+
+- add information to ``self.client.ok_message`` or ``self.client.error_message``
+- change the ``self.client.template`` variable to alter what the user will see
+  next
+- raise Unauthorised, SendStaticFile, SendFile, NotFound or Redirect
+  exceptions (import them from roundup.cgi.exceptions)
+
+
+Register the action class
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The class is now written, but isn't available to the user until you register
+it with the following code appended to your ``myaction.py`` file::
+
+    def init(instance):
+        instance.registerAction('myaction', myActionClass)
+
+This maps the action name "myaction" to the action class we defined.
+
+
+Use the new action
+~~~~~~~~~~~~~~~~~~
+
+In your HTML form, add a hidden form element like so::
+
+  <input type="hidden" name="@action" value="myaction">
+
+where "myaction" is the name you registered in the previous step.
+
+Actions may return content to the user
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Actions generally perform some database manipulation and then pass control
+on to the rendering of a template in the current context (see `Determining
+web context`_ for how that works.) Some actions will want to generate the
+actual content returned to the user. Action methods may return their own
+content string to be displayed to the user, overriding the templating step.
+In this situation, we assume that the content is HTML by default. You may
+override the content type indicated to the user by calling ``setHeader``::
+
+   self.client.setHeader('Content-Type', 'text/csv')
+
+This example indicates that the value sent back to the user is actually
+comma-separated value content (eg. something to be loaded into a
+spreadsheet or database).
+
+
+8-bit character set support in Web interface
+--------------------------------------------
+
+The web interface uses UTF-8 default. It may be overridden in both forms
+and a browser cookie.
+
+- In forms, use the ``@charset`` variable.
+- To use the cookie override, have the ``roundup_charset`` cookie set.
+
+In both cases, the value is a valid charset name (eg. ``utf-8`` or
+``kio8-r``).
+
+Inside Roundup, all strings are stored and processed in utf-8.
+Unfortunately, some older browsers do not work properly with
+utf-8-encoded pages (e.g. Netscape Navigator 4 displays wrong
+characters in form fields).  This version allows one to change
+the character set for http transfers.  To do so, you may add
+the following code to your ``page.html`` template::
+
+ <tal:block define="uri string:${request/base}${request/env/PATH_INFO}">
+  <a tal:attributes="href python:request.indexargs_url(uri,
+   {'@charset':'utf-8'})">utf-8</a>
+  <a tal:attributes="href python:request.indexargs_url(uri,
+   {'@charset':'koi8-r'})">koi8-r</a>
+ </tal:block>
+
+(substitute ``koi8-r`` with appropriate charset for your language).
+Charset preference is kept in the browser cookie ``roundup_charset``.
+
+``meta http-equiv`` lines added to the tracker templates in version 0.6.0
+should be changed to include actual character set name::
+
+ <meta http-equiv="Content-Type"
+  tal:attributes="content string:text/html;; charset=${request/client/charset}"
+ />
+
+The charset is also sent in the http header.
+
+
+Examples
+========
+
+.. contents::
+   :local:
+   :depth: 2
+
+
+Changing what's stored in the database
+--------------------------------------
+
+The following examples illustrate ways to change the information stored in
+the database.
+
+
+Adding a new field to the classic schema
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+This example shows how to add a simple field (a due date) to the default
+classic schema. It does not add any additional behaviour, such as enforcing
+the due date, or causing automatic actions to fire if the due date passes.
+
+You add new fields by editing the ``schema.py`` file in you tracker's home.
+Schema changes are automatically applied to the database on the next
+tracker access (note that roundup-server would need to be restarted as it
+caches the schema).
+
+1. modify the schema::
+
+    issue = IssueClass(db, "issue", 
+                    assignedto=Link("user"), topic=Multilink("keyword"),
+                    priority=Link("priority"), status=Link("status"),
+                    due_date=Date())
+
+2. add an edit field to the issue.item.html template::
+
+    <tr> 
+     <th>Due Date</th> 
+     <td tal:content="structure context/due_date/field" /> 
+    </tr> 
+
+3. add the property to the issue.index.html page::
+
+    (in the heading row)
+      <th tal:condition="request/show/due_date">Due Date</th>
+    (in the data row)
+      <td tal:condition="request/show/due_date" tal:content="i/due_date" />
+
+4. add the property to the issue.search.html page::
+
+     <tr tal:define="name string:due_date">
+       <th i18n:translate="">Due Date:</th>
+       <td metal:use-macro="search_input"></td>
+       <td metal:use-macro="column_input"></td>
+       <td metal:use-macro="sort_input"></td>
+       <td metal:use-macro="group_input"></td>
+     </tr>
+
+
+Adding a new constrained field to the classic schema
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+This example shows how to add a new constrained property (i.e. a
+selection of distinct values) to your tracker.
+
+
+Introduction
+::::::::::::
+
+To make the classic schema of Roundup useful as a TODO tracking system
+for a group of systems administrators, it needs an extra data field per
+issue: a category.
+
+This would let sysadmins quickly list all TODOs in their particular area
+of interest without having to do complex queries, and without relying on
+the spelling capabilities of other sysadmins (a losing proposition at
+best).
+
+
+Adding a field to the database
+::::::::::::::::::::::::::::::
+
+This is the easiest part of the change. The category would just be a
+plain string, nothing fancy. To change what is in the database you need
+to add some lines to the ``schema.py`` file of your tracker instance.
+Under the comment::
+
+    # add any additional database schema configuration here
+
+add::
+
+    category = Class(db, "category", name=String())
+    category.setkey("name")
+
+Here we are setting up a chunk of the database which we are calling
+"category". It contains a string, which we are refering to as "name" for
+lack of a more imaginative title. (Since "name" is one of the properties
+that Roundup looks for on items if you do not set a key for them, it's
+probably a good idea to stick with it for new classes if at all
+appropriate.) Then we are setting the key of this chunk of the database
+to be that "name". This is equivalent to an index for database types.
+This also means that there can only be one category with a given name.
+
+Adding the above lines allows us to create categories, but they're not
+tied to the issues that we are going to be creating. It's just a list of
+categories off on its own, which isn't much use. We need to link it in
+with the issues. To do that, find the lines 
+in ``schema.py`` which set up the "issue" class, and then add a link to
+the category::
+
+    issue = IssueClass(db, "issue", ... ,
+        category=Multilink("category"), ... )
+
+The ``Multilink()`` means that each issue can have many categories. If
+you were adding something with a one-to-one relationship to issues (such
+as the "assignedto" property), use ``Link()`` instead.
+
+That is all you need to do to change the schema. The rest of the effort
+is fiddling around so you can actually use the new category.
+
+
+Populating the new category class
+:::::::::::::::::::::::::::::::::
+
+If you haven't initialised the database with the ``roundup-admin``
+"initialise" command, then you can add the following to the tracker
+``initial_data.py`` under the comment::
+
+    # add any additional database creation steps here - but only if you
+    # haven't initialised the database with the admin "initialise" command
+
+Add::
+
+     category = db.getclass('category')
+     category.create(name="scipy")
+     category.create(name="chaco")
+     category.create(name="weave")
+
+If the database has already been initalised, then you need to use the
+``roundup-admin`` tool::
+
+     % roundup-admin -i <tracker home>
+     Roundup <version> ready for input.
+     Type "help" for help.
+     roundup> create category name=scipy
+     1
+     roundup> create category name=chaco
+     2
+     roundup> create category name=weave
+     3
+     roundup> exit...
+     There are unsaved changes. Commit them (y/N)? y
+
+
+Setting up security on the new objects
+::::::::::::::::::::::::::::::::::::::
+
+By default only the admin user can look at and change objects. This
+doesn't suit us, as we want any user to be able to create new categories
+as required, and obviously everyone needs to be able to view the
+categories of issues for it to be useful.
+
+We therefore need to change the security of the category objects. This
+is also done in ``schema.py``.
+
+There are currently two loops which set up permissions and then assign
+them to various roles. Simply add the new "category" to both lists::
+
+    # Assign the access and edit permissions for issue, file and message
+    # to regular users now
+    for cl in 'issue', 'file', 'msg', 'category':
+        p = db.security.getPermission('View', cl)
+        db.security.addPermissionToRole('User', 'View', cl)
+        db.security.addPermissionToRole('User', 'Edit', cl)
+        db.security.addPermissionToRole('User', 'Create', cl)
+
+These lines assign the "View" and "Edit" Permissions to the "User" role,
+so that normal users can view and edit "category" objects.
+
+This is all the work that needs to be done for the database. It will
+store categories, and let users view and edit them. Now on to the
+interface stuff.
+
+
+Changing the web left hand frame
+::::::::::::::::::::::::::::::::
+
+We need to give the users the ability to create new categories, and the
+place to put the link to this functionality is in the left hand function
+bar, under the "Issues" area. The file that defines how this area looks
+is ``html/page.html``, which is what we are going to be editing next.
+
+If you look at this file you can see that it contains a lot of
+"classblock" sections which are chunks of HTML that will be included or
+excluded in the output depending on whether the condition in the
+classblock is met. We are going to add the category code at the end of
+the classblock for the *issue* class::
+
+  <p class="classblock"
+     tal:condition="python:request.user.hasPermission('View', 'category')">
+   <b>Categories</b><br>
+   <a tal:condition="python:request.user.hasPermission('Edit', 'category')"
+      href="category?@template=item">New Category<br></a>
+  </p>
+
+The first two lines is the classblock definition, which sets up a
+condition that only users who have "View" permission for the "category"
+object will have this section included in their output. Next comes a
+plain "Categories" header in bold. Everyone who can view categories will
+get that.
+
+Next comes the link to the editing area of categories. This link will
+only appear if the condition - that the user has "Edit" permissions for
+the "category" objects - is matched. If they do have permission then
+they will get a link to another page which will let the user add new
+categories.
+
+Note that if you have permission to *view* but not to *edit* categories,
+then all you will see is a "Categories" header with nothing underneath
+it. This is obviously not very good interface design, but will do for
+now. I just claim that it is so I can add more links in this section
+later on. However, to fix the problem you could change the condition in
+the classblock statement, so that only users with "Edit" permission
+would see the "Categories" stuff.
+
+
+Setting up a page to edit categories
+::::::::::::::::::::::::::::::::::::
+
+We defined code in the previous section which let users with the
+appropriate permissions see a link to a page which would let them edit
+conditions. Now we have to write that page.
+
+The link was for the *item* template of the *category* object. This
+translates into Roundup looking for a file called ``category.item.html``
+in the ``html`` tracker directory. This is the file that we are going to
+write now.
+
+First, we add an info tag in a comment which doesn't affect the outcome
+of the code at all, but is useful for debugging. If you load a page in a
+browser and look at the page source, you can see which sections come
+from which files by looking for these comments::
+
+    <!-- category.item -->
+
+Next we need to add in the METAL macro stuff so we get the normal page
+trappings::
+
+ <tal:block metal:use-macro="templates/page/macros/icing">
+  <title metal:fill-slot="head_title">Category editing</title>
+  <td class="page-header-top" metal:fill-slot="body_title">
+   <h2>Category editing</h2>
+  </td>
+  <td class="content" metal:fill-slot="content">
+
+Next we need to setup up a standard HTML form, which is the whole
+purpose of this file. We link to some handy javascript which sends the
+form through only once. This is to stop users hitting the send button
+multiple times when they are impatient and thus having the form sent
+multiple times::
+
+    <form method="POST" onSubmit="return submit_once()"
+          enctype="multipart/form-data">
+
+Next we define some code which sets up the minimum list of fields that
+we require the user to enter. There will be only one field - "name" - so
+they better put something in it, otherwise the whole form is pointless::
+
+    <input type="hidden" name="@required" value="name">
+
+To get everything to line up properly we will put everything in a table,
+and put a nice big header on it so the user has an idea what is
+happening::
+
+    <table class="form">
+     <tr><th class="header" colspan="2">Category</th></tr>
+
+Next, we need the field into which the user is going to enter the new
+category. The ``context.name.field(size=60)`` bit tells Roundup to
+generate a normal HTML field of size 60, and the contents of that field
+will be the "name" variable of the current context (namely "category").
+The upshot of this is that when the user types something in
+to the form, a new category will be created with that name::
+
+    <tr>
+     <th>Name</th>
+     <td tal:content="structure python:context.name.field(size=60)">
+     name</td>
+    </tr>
+
+Then a submit button so that the user can submit the new category::
+
+    <tr>
+     <td>&nbsp;</td>
+     <td colspan="3" tal:content="structure context/submit">
+      submit button will go here
+     </td>
+    </tr>
+
+Finally we finish off the tags we used at the start to do the METAL
+stuff::
+
+  </td>
+ </tal:block>
+
+So putting it all together, and closing the table and form we get::
+
+ <!-- category.item -->
+ <tal:block metal:use-macro="templates/page/macros/icing">
+  <title metal:fill-slot="head_title">Category editing</title>
+  <td class="page-header-top" metal:fill-slot="body_title">
+   <h2>Category editing</h2>
+  </td>
+  <td class="content" metal:fill-slot="content">
+   <form method="POST" onSubmit="return submit_once()"
+         enctype="multipart/form-data">
+
+    <table class="form">
+     <tr><th class="header" colspan="2">Category</th></tr>
+
+     <tr>
+      <th>Name</th>
+      <td tal:content="structure python:context.name.field(size=60)">
+      name</td>
+     </tr>
+
+     <tr>
+      <td>
+        &nbsp;
+        <input type="hidden" name="@required" value="name"> 
+      </td>
+      <td colspan="3" tal:content="structure context/submit">
+       submit button will go here
+      </td>
+     </tr>
+    </table>
+   </form>
+  </td>
+ </tal:block>
+
+This is quite a lot to just ask the user one simple question, but there
+is a lot of setup for basically one line (the form line) to do its work.
+To add another field to "category" would involve one more line (well,
+maybe a few extra to get the formatting correct).
+
+
+Adding the category to the issue
+::::::::::::::::::::::::::::::::
+
+We now have the ability to create issues to our heart's content, but
+that is pointless unless we can assign categories to issues.  Just like
+the ``html/category.item.html`` file was used to define how to add a new
+category, the ``html/issue.item.html`` is used to define how a new issue
+is created.
+
+Just like ``category.issue.html``, this file defines a form which has a
+table to lay things out. It doesn't matter where in the table we add new
+stuff, it is entirely up to your sense of aesthetics::
+
+   <th>Category</th>
+   <td>
+    <span tal:replace="structure context/category/field" />
+    <span tal:replace="structure python:db.category.classhelp('name',
+                property='category', width='200')" />
+   </td>
+
+First, we define a nice header so that the user knows what the next
+section is, then the middle line does what we are most interested in.
+This ``context/category/field`` gets replaced by a field which contains
+the category in the current context (the current context being the new
+issue).
+
+The classhelp lines generate a link (labelled "list") to a popup window
+which contains the list of currently known categories.
+
+
+Searching on categories
+:::::::::::::::::::::::
+
+Now we can add categories, and create issues with categories. The next
+obvious thing that we would like to be able to do, would be to search
+for issues based on their category, so that, for example, anyone working
+on the web server could look at all issues in the category "Web".
+
+If you look for "Search Issues" in the ``html/page.html`` file, you will
+find that it looks something like 
+``<a href="issue?@template=search">Search Issues</a>``. This shows us
+that when you click on "Search Issues" it will be looking for a
+``issue.search.html`` file to display. So that is the file that we will
+change.
+
+If you look at this file it should begin to seem familiar, although it
+does use some new macros. You can add the new category search code anywhere you
+like within that form::
+
+  <tr tal:define="name string:category;
+                  db_klass string:category;
+                  db_content string:name;">
+    <th>Priority:</th>
+    <td metal:use-macro="search_select"></td>
+    <td metal:use-macro="column_input"></td>
+    <td metal:use-macro="sort_input"></td>
+    <td metal:use-macro="group_input"></td>
+  </tr>
+
+The definitions in the ``<tr>`` opening tag are used by the macros:
+
+- ``search_select`` expands to a drop-down box with all categories using
+  ``db_klass`` and ``db_content``.
+- ``column_input`` expands to a checkbox for selecting what columns
+  should be displayed.
+- ``sort_input`` expands to a radio button for selecting what property
+  should be sorted on.
+- ``group_input`` expands to a radio button for selecting what property
+  should be grouped on.
+
+The category search code above would expand to the following::
+
+  <tr>
+    <th>Category:</th>
+    <td>
+      <select name="category">
+        <option value="">don't care</option>
+        <option value="">------------</option>      
+        <option value="1">scipy</option>
+        <option value="2">chaco</option>
+        <option value="3">weave</option>
+      </select>
+    </td>
+    <td><input type="checkbox" name=":columns" value="category"></td>
+    <td><input type="radio" name=":sort" value="category"></td>
+    <td><input type="radio" name=":group" value="category"></td>
+  </tr>
+
+Adding category to the default view
+:::::::::::::::::::::::::::::::::::
+
+We can now add categories, add issues with categories, and search for
+issues based on categories. This is everything that we need to do;
+however, there is some more icing that we would like. I think the
+category of an issue is important enough that it should be displayed by
+default when listing all the issues.
+
+Unfortunately, this is a bit less obvious than the previous steps. The
+code defining how the issues look is in ``html/issue.index.html``. This
+is a large table with a form down at the bottom for redisplaying and so
+forth. 
+
+Firstly we need to add an appropriate header to the start of the table::
+
+    <th tal:condition="request/show/category">Category</th>
+
+The *condition* part of this statement is to avoid displaying the
+Category column if the user has selected not to see it.
+
+The rest of the table is a loop which will go through every issue that
+matches the display criteria. The loop variable is "i" - which means
+that every issue gets assigned to "i" in turn.
+
+The new part of code to display the category will look like this::
+
+    <td tal:condition="request/show/category"
+        tal:content="i/category"></td>
+
+The condition is the same as above: only display the condition when the
+user hasn't asked for it to be hidden. The next part is to set the
+content of the cell to be the category part of "i" - the current issue.
+
+Finally we have to edit ``html/page.html`` again. This time, we need to
+tell it that when the user clicks on "Unassigned Issues" or "All Issues",
+the category column should be included in the resulting list. If you
+scroll down the page file, you can see the links with lots of options.
+The option that we are interested in is the ``:columns=`` one which
+tells roundup which fields of the issue to display. Simply add
+"category" to that list and it all should work.
+
+Adding a time log to your issues
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+We want to log the dates and amount of time spent working on issues, and
+be able to give a summary of the total time spent on a particular issue.
+
+1. Add a new class to your tracker ``schema.py``::
+
+    # storage for time logging
+    timelog = Class(db, "timelog", period=Interval())
+
+   Note that we automatically get the date of the time log entry
+   creation through the standard property "creation".
+
+2. Link to the new class from your issue class (again, in
+   ``schema.py``)::
+
+    issue = IssueClass(db, "issue", 
+                    assignedto=Link("user"), topic=Multilink("keyword"),
+                    priority=Link("priority"), status=Link("status"),
+                    times=Multilink("timelog"))
+
+   the "times" property is the new link to the "timelog" class.
+
+3. We'll need to let people add in times to the issue, so in the web
+   interface we'll have a new entry field. This is a special field
+   because unlike the other fields in the ``issue.item`` template, it
+   affects a different item (a timelog item) and not the template's
+   item (an issue). We have a special syntax for form fields that affect
+   items other than the template default item (see the cgi 
+   documentation on `special form variables`_). In particular, we add a
+   field to capture a new timelog item's period::
+
+    <tr> 
+     <th>Time Log</th> 
+     <td colspan=3><input type="text" name="timelog-1 at period" /> 
+      <br />(enter as '3y 1m 4d 2:40:02' or parts thereof) 
+     </td> 
+    </tr> 
+         
+   and another hidden field that links that new timelog item (new
+   because it's marked as having id "-1") to the issue item. It looks
+   like this::
+
+     <input type="hidden" name="@link at times" value="timelog-1" />
+
+   On submission, the "-1" timelog item will be created and assigned a
+   real item id. The "times" property of the issue will have the new id
+   added to it.
+
+4. We want to display a total of the timelog times that have been
+   accumulated for an issue. To do this, we'll need to actually write
+   some Python code, since it's beyond the scope of PageTemplates to
+   perform such calculations. We do this by adding a module ``timespent.py``
+   to the ``extensions`` directory in our tracker. The contents of this
+   file is as follows::
+
+    def totalTimeSpent(times):
+        ''' Call me with a list of timelog items (which have an
+            Interval "period" property)
+        '''
+        total = Interval('0d')
+        for time in times:
+            total += time.period._value
+        return total
+
+    def init(instance):
+        instance.registerUtil('totalTimeSpent', totalTimeSpent)
+
+   We will now be able to access the ``totalTimeSpent`` function via the
+   ``utils`` variable in our templates, as shown in the next step.
+
+5. Display the timelog for an issue::
+
+     <table class="otherinfo" tal:condition="context/times">
+      <tr><th colspan="3" class="header">Time Log
+       <tal:block
+            tal:replace="python:utils.totalTimeSpent(context.times)" />
+      </th></tr>
+      <tr><th>Date</th><th>Period</th><th>Logged By</th></tr>
+      <tr tal:repeat="time context/times">
+       <td tal:content="time/creation"></td>
+       <td tal:content="time/period"></td>
+       <td tal:content="time/creator"></td>
+      </tr>
+     </table>
+
+   I put this just above the Messages log in my issue display. Note our
+   use of the ``totalTimeSpent`` method which will total up the times
+   for the issue and return a new Interval. That will be automatically
+   displayed in the template as text like "+ 1y 2:40" (1 year, 2 hours
+   and 40 minutes).
+
+8. If you're using a persistent web server - ``roundup-server`` or
+   ``mod_python`` for example - then you'll need to restart that to pick up
+   the code changes. When that's done, you'll be able to use the new
+   time logging interface.
+
+
+Tracking different types of issues
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Sometimes you will want to track different types of issues - developer,
+customer support, systems, sales leads, etc. A single Roundup tracker is
+able to support multiple types of issues. This example demonstrates adding
+a system support issue class to a tracker.
+
+1. Figure out what information you're going to want to capture. OK, so
+   this is obvious, but sometimes it's better to actually sit down for a
+   while and think about the schema you're going to implement.
+
+2. Add the new issue class to your tracker's ``schema.py``. Just after the
+   "issue" class definition, add::
+
+    # list our systems
+    system = Class(db, "system", name=String(), order=Number())
+    system.setkey("name")
+
+    # store issues related to those systems
+    support = IssueClass(db, "support", 
+                    assignedto=Link("user"), topic=Multilink("keyword"),
+                    status=Link("status"), deadline=Date(),
+                    affects=Multilink("system"))
+
+3. Copy the existing ``issue.*`` (item, search and index) templates in the
+   tracker's ``html`` to ``support.*``. Edit them so they use the properties
+   defined in the ``support`` class. Be sure to check for hidden form
+   variables like "required" to make sure they have the correct set of
+   required properties.
+
+4. Edit the modules in the ``detectors``, adding lines to their ``init``
+   functions where appropriate. Look for ``audit`` and ``react`` registrations
+   on the ``issue`` class, and duplicate them for ``support``.
+
+5. Create a new sidebar box for the new support class. Duplicate the
+   existing issues one, changing the ``issue`` class name to ``support``.
+
+6. Re-start your tracker and start using the new ``support`` class.
+
+
+Optionally, you might want to restrict the users able to access this new
+class to just the users with a new "SysAdmin" Role. To do this, we add
+some security declarations::
+
+    db.security.addPermissionToRole('SysAdmin', 'View', 'support')
+    db.security.addPermissionToRole('SysAdmin', 'Create', 'support')
+    db.security.addPermissionToRole('SysAdmin', 'Edit', 'support')
+
+You would then (as an "admin" user) edit the details of the appropriate
+users, and add "SysAdmin" to their Roles list.
+
+Alternatively, you might want to change the Edit/View permissions granted
+for the ``issue`` class so that it's only available to users with the "System"
+or "Developer" Role, and then the new class you're adding is available to
+all with the "User" Role.
+
+
+Using External User Databases
+-----------------------------
+
+Using an external password validation source
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. note:: You will need to either have an "admin" user in your external
+          password source *or* have one of your regular users have
+          the Admin Role assigned. If you need to assign the Role *after*
+          making the changes below, you may use the ``roundup-admin``
+          program to edit a user's details.
+
+We have a centrally-managed password changing system for our users. This
+results in a UN*X passwd-style file that we use for verification of
+users. Entries in the file consist of ``name:password`` where the
+password is encrypted using the standard UN*X ``crypt()`` function (see
+the ``crypt`` module in your Python distribution). An example entry
+would be::
+
+    admin:aamrgyQfDFSHw
+
+Each user of Roundup must still have their information stored in the Roundup
+database - we just use the passwd file to check their password. To do this, we
+need to override the standard ``verifyPassword`` method defined in
+``roundup.cgi.actions.LoginAction`` and register the new class. The
+following is added as ``externalpassword.py`` in the tracker ``extensions``
+directory::
+
+    import os, crypt
+    from roundup.cgi.actions import LoginAction    
+
+    class ExternalPasswordLoginAction(LoginAction):
+        def verifyPassword(self, userid, password):
+            '''Look through the file, line by line, looking for a
+            name that matches.
+            '''
+            # get the user's username
+            username = self.db.user.get(userid, 'username')
+
+            # the passwords are stored in the "passwd.txt" file in the
+            # tracker home
+            file = os.path.join(self.db.config.TRACKER_HOME, 'passwd.txt')
+
+            # see if we can find a match
+            for ent in [line.strip().split(':') for line in
+                                                open(file).readlines()]:
+                if ent[0] == username:
+                    return crypt.crypt(password, ent[1][:2]) == ent[1]
+
+            # user doesn't exist in the file
+            return 0
+
+    def init(instance):
+        instance.registerAction('login', ExternalPasswordLoginAction)
+
+You should also remove the redundant password fields from the ``user.item``
+template.
+
+
+Using a UN*X passwd file as the user database
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+On some systems the primary store of users is the UN*X passwd file. It
+holds information on users such as their username, real name, password
+and primary user group.
+
+Roundup can use this store as its primary source of user information,
+but it needs additional information too - email address(es), roundup
+Roles, vacation flags, roundup hyperdb item ids, etc. Also, "retired"
+users must still exist in the user database, unlike some passwd files in
+which the users are removed when they no longer have access to a system.
+
+To make use of the passwd file, we therefore synchronise between the two
+user stores. We also use the passwd file to validate the user logins, as
+described in the previous example, `using an external password
+validation source`_. We keep the user lists in sync using a fairly
+simple script that runs once a day, or several times an hour if more
+immediate access is needed. In short, it:
+
+1. parses the passwd file, finding usernames, passwords and real names,
+2. compares that list to the current roundup user list:
+
+   a. entries no longer in the passwd file are *retired*
+   b. entries with mismatching real names are *updated*
+   c. entries only exist in the passwd file are *created*
+
+3. send an email to administrators to let them know what's been done.
+
+The retiring and updating are simple operations, requiring only a call
+to ``retire()`` or ``set()``. The creation operation requires more
+information though - the user's email address and their Roundup Roles.
+We're going to assume that the user's email address is the same as their
+login name, so we just append the domain name to that. The Roles are
+determined using the passwd group identifier - mapping their UN*X group
+to an appropriate set of Roles.
+
+The script to perform all this, broken up into its main components, is
+as follows. Firstly, we import the necessary modules and open the
+tracker we're to work on::
+
+    import sys, os, smtplib
+    from roundup import instance, date
+
+    # open the tracker
+    tracker_home = sys.argv[1]
+    tracker = instance.open(tracker_home)
+
+Next we read in the *passwd* file from the tracker home::
+
+    # read in the users from the "passwd.txt" file
+    file = os.path.join(tracker_home, 'passwd.txt')
+    users = [x.strip().split(':') for x in open(file).readlines()]
+
+Handle special users (those to ignore in the file, and those who don't
+appear in the file)::
+
+    # users to not keep ever, pre-load with the users I know aren't
+    # "real" users
+    ignore = ['ekmmon', 'bfast', 'csrmail']
+
+    # users to keep - pre-load with the roundup-specific users
+    keep = ['comment_pool', 'network_pool', 'admin', 'dev-team',
+            'cs_pool', 'anonymous', 'system_pool', 'automated']
+
+Now we map the UN*X group numbers to the Roles that users should have::
+
+    roles = {
+     '501': 'User,Tech',  # tech
+     '502': 'User',       # finance
+     '503': 'User,CSR',   # customer service reps
+     '504': 'User',       # sales
+     '505': 'User',       # marketing
+    }
+
+Now we do all the work. Note that the body of the script (where we have
+the tracker database open) is wrapped in a ``try`` / ``finally`` clause,
+so that we always close the database cleanly when we're finished. So, we
+now do all the work::
+
+    # open the database
+    db = tracker.open('admin')
+    try:
+        # store away messages to send to the tracker admins
+        msg = []
+
+        # loop over the users list read in from the passwd file
+        for user,passw,uid,gid,real,home,shell in users:
+            if user in ignore:
+                # this user shouldn't appear in our tracker
+                continue
+            keep.append(user)
+            try:
+                # see if the user exists in the tracker
+                uid = db.user.lookup(user)
+
+                # yes, they do - now check the real name for correctness
+                if real != db.user.get(uid, 'realname'):
+                    db.user.set(uid, realname=real)
+                    msg.append('FIX %s - %s'%(user, real))
+            except KeyError:
+                # nope, the user doesn't exist
+                db.user.create(username=user, realname=real,
+                    address='%s at ekit-inc.com'%user, roles=roles[gid])
+                msg.append('ADD %s - %s (%s)'%(user, real, roles[gid]))
+
+        # now check that all the users in the tracker are also in our
+        # "keep" list - retire those who aren't
+        for uid in db.user.list():
+            user = db.user.get(uid, 'username')
+            if user not in keep:
+                db.user.retire(uid)
+                msg.append('RET %s'%user)
+
+        # if we did work, then send email to the tracker admins
+        if msg:
+            # create the email
+            msg = '''Subject: %s user database maintenance
+
+            %s
+            '''%(db.config.TRACKER_NAME, '\n'.join(msg))
+
+            # send the email
+            smtp = smtplib.SMTP(db.config.MAILHOST)
+            addr = db.config.ADMIN_EMAIL
+            smtp.sendmail(addr, addr, msg)
+
+        # now we're done - commit the changes
+        db.commit()
+    finally:
+        # always close the database cleanly
+        db.close()
+
+And that's it!
+
+
+Using an LDAP database for user information
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+A script that reads users from an LDAP store using
+http://python-ldap.sf.net/ and then compares the list to the users in the
+roundup user database would be pretty easy to write. You'd then have it run
+once an hour / day (or on demand if you can work that into your LDAP store
+workflow). See the example `Using a UN*X passwd file as the user database`_
+for more information about doing this.
+
+To authenticate off the LDAP store (rather than using the passwords in the
+Roundup user database) you'd use the same python-ldap module inside an
+extension to the cgi interface. You'd do this by overriding the method called
+``verifyPassword`` on the ``LoginAction`` class in your tracker's
+``extensions`` directory (see `using an external password validation
+source`_). The method is implemented by default as::
+
+    def verifyPassword(self, userid, password):
+        ''' Verify the password that the user has supplied
+        '''
+        stored = self.db.user.get(self.userid, 'password')
+        if password == stored:
+            return 1
+        if not password and not stored:
+            return 1
+        return 0
+
+So you could reimplement this as something like::
+
+    def verifyPassword(self, userid, password):
+        ''' Verify the password that the user has supplied
+        '''
+        # look up some unique LDAP information about the user
+        username = self.db.user.get(self.userid, 'username')
+        # now verify the password supplied against the LDAP store
+
+
+Changes to Tracker Behaviour
+----------------------------
+
+Stop "nosy" messages going to people on vacation
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+When users go on vacation and set up vacation email bouncing, you'll
+start to see a lot of messages come back through Roundup "Fred is on
+vacation". Not very useful, and relatively easy to stop.
+
+1. add a "vacation" flag to your users::
+
+         user = Class(db, "user",
+                    username=String(),   password=Password(),
+                    address=String(),    realname=String(),
+                    phone=String(),      organisation=String(),
+                    alternate_addresses=String(),
+                    roles=String(), queries=Multilink("query"),
+                    vacation=Boolean())
+
+2. So that users may edit the vacation flags, add something like the
+   following to your ``user.item`` template::
+
+     <tr>
+      <th>On Vacation</th> 
+      <td tal:content="structure context/vacation/field">vacation</td> 
+     </tr> 
+
+3. edit your detector ``nosyreactor.py`` so that the ``nosyreaction()``
+   consists of::
+
+    def nosyreaction(db, cl, nodeid, oldvalues):
+        users = db.user
+        messages = db.msg
+        # send a copy of all new messages to the nosy list
+        for msgid in determineNewMessages(cl, nodeid, oldvalues):
+            try:
+                # figure the recipient ids
+                sendto = []
+                seen_message = {}
+                recipients = messages.get(msgid, 'recipients')
+                for recipid in messages.get(msgid, 'recipients'):
+                    seen_message[recipid] = 1
+
+                # figure the author's id, and indicate they've received
+                # the message
+                authid = messages.get(msgid, 'author')
+
+                # possibly send the message to the author, as long as
+                # they aren't anonymous
+                if (db.config.MESSAGES_TO_AUTHOR == 'yes' and
+                        users.get(authid, 'username') != 'anonymous'):
+                    sendto.append(authid)
+                seen_message[authid] = 1
+
+                # now figure the nosy people who weren't recipients
+                nosy = cl.get(nodeid, 'nosy')
+                for nosyid in nosy:
+                    # Don't send nosy mail to the anonymous user (that
+                    # user shouldn't appear in the nosy list, but just
+                    # in case they do...)
+                    if users.get(nosyid, 'username') == 'anonymous':
+                        continue
+                    # make sure they haven't seen the message already
+                    if not seen_message.has_key(nosyid):
+                        # send it to them
+                        sendto.append(nosyid)
+                        recipients.append(nosyid)
+
+                # generate a change note
+                if oldvalues:
+                    note = cl.generateChangeNote(nodeid, oldvalues)
+                else:
+                    note = cl.generateCreateNote(nodeid)
+
+                # we have new recipients
+                if sendto:
+                    # filter out the people on vacation
+                    sendto = [i for i in sendto 
+                              if not users.get(i, 'vacation', 0)]
+
+                    # map userids to addresses
+                    sendto = [users.get(i, 'address') for i in sendto]
+
+                    # update the message's recipients list
+                    messages.set(msgid, recipients=recipients)
+
+                    # send the message
+                    cl.send_message(nodeid, msgid, note, sendto)
+            except roundupdb.MessageSendError, message:
+                raise roundupdb.DetectorError, message
+
+   Note that this is the standard nosy reaction code, with the small
+   addition of::
+
+    # filter out the people on vacation
+    sendto = [i for i in sendto if not users.get(i, 'vacation', 0)]
+
+   which filters out the users that have the vacation flag set to true.
+
+Adding in state transition control
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Sometimes tracker admins want to control the states to which users may
+move issues. You can do this by following these steps:
+
+1. make "status" a required variable. This is achieved by adding the
+   following to the top of the form in the ``issue.item.html``
+   template::
+
+     <input type="hidden" name="@required" value="status">
+
+   This will force users to select a status.
+
+2. add a Multilink property to the status class::
+
+     stat = Class(db, "status", ... , transitions=Multilink('status'),
+                  ...)
+
+   and then edit the statuses already created, either:
+
+   a. through the web using the class list -> status class editor, or
+   b. using the ``roundup-admin`` "set" command.
+
+3. add an auditor module ``checktransition.py`` in your tracker's
+   ``detectors`` directory, for example::
+
+     def checktransition(db, cl, nodeid, newvalues):
+         ''' Check that the desired transition is valid for the "status"
+             property.
+         '''
+         if not newvalues.has_key('status'):
+             return
+         current = cl.get(nodeid, 'status')
+         new = newvalues['status']
+         if new == current:
+             return
+         ok = db.status.get(current, 'transitions')
+         if new not in ok:
+             raise ValueError, 'Status not allowed to move from "%s" to "%s"'%(
+                 db.status.get(current, 'name'), db.status.get(new, 'name'))
+
+     def init(db):
+         db.issue.audit('set', checktransition)
+
+4. in the ``issue.item.html`` template, change the status editing bit
+   from::
+
+    <th>Status</th>
+    <td tal:content="structure context/status/menu">status</td>
+
+   to::
+
+    <th>Status</th>
+    <td>
+     <select tal:condition="context/id" name="status">
+      <tal:block tal:define="ok context/status/transitions"
+                 tal:repeat="state db/status/list">
+       <option tal:condition="python:state.id in ok"
+               tal:attributes="
+                    value state/id;
+                    selected python:state.id == context.status.id"
+               tal:content="state/name"></option>
+      </tal:block>
+     </select>
+     <tal:block tal:condition="not:context/id"
+                tal:replace="structure context/status/menu" />
+    </td>
+
+   which displays only the allowed status to transition to.
+
+
+Blocking issues that depend on other issues
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+We needed the ability to mark certain issues as "blockers" - that is,
+they can't be resolved until another issue (the blocker) they rely on is
+resolved. To achieve this:
+
+1. Create a new property on the ``issue`` class:
+   ``blockers=Multilink("issue")``. To do this, edit the definition of
+   this class in your tracker's ``schema.py`` file. Change this::
+
+    issue = IssueClass(db, "issue", 
+                    assignedto=Link("user"), topic=Multilink("keyword"),
+                    priority=Link("priority"), status=Link("status"))
+
+   to this, adding the blockers entry::
+
+    issue = IssueClass(db, "issue", 
+                    blockers=Multilink("issue"),
+                    assignedto=Link("user"), topic=Multilink("keyword"),
+                    priority=Link("priority"), status=Link("status"))
+
+2. Add the new ``blockers`` property to the ``issue.item.html`` edit
+   page, using something like::
+
+    <th>Waiting On</th>
+    <td>
+     <span tal:replace="structure python:context.blockers.field(showid=1,
+                                  size=20)" />
+     <span tal:replace="structure python:db.issue.classhelp('id,title')" />
+     <span tal:condition="context/blockers"
+           tal:repeat="blk context/blockers">
+      <br>View: <a tal:attributes="href string:issue${blk/id}"
+                   tal:content="blk/id"></a>
+     </span>
+
+   You'll need to fiddle with your item page layout to find an
+   appropriate place to put it - I'll leave that fun part up to you.
+   Just make sure it appears in the first table, possibly somewhere near
+   the "superseders" field.
+
+3. Create a new detector module (see below) which enforces the rules:
+
+   - issues may not be resolved if they have blockers
+   - when a blocker is resolved, it's removed from issues it blocks
+
+   The contents of the detector should be something like this::
+
+
+    def blockresolution(db, cl, nodeid, newvalues):
+        ''' If the issue has blockers, don't allow it to be resolved.
+        '''
+        if nodeid is None:
+            blockers = []
+        else:
+            blockers = cl.get(nodeid, 'blockers')
+        blockers = newvalues.get('blockers', blockers)
+
+        # don't do anything if there's no blockers or the status hasn't
+        # changed
+        if not blockers or not newvalues.has_key('status'):
+            return
+
+        # get the resolved state ID
+        resolved_id = db.status.lookup('resolved')
+
+        # format the info
+        u = db.config.TRACKER_WEB
+        s = ', '.join(['<a href="%sissue%s">%s</a>'%(
+                        u,id,id) for id in blockers])
+        if len(blockers) == 1:
+            s = 'issue %s is'%s
+        else:
+            s = 'issues %s are'%s
+
+        # ok, see if we're trying to resolve
+        if newvalues['status'] == resolved_id:
+            raise ValueError, "This issue can't be resolved until %s resolved."%s
+
+
+    def resolveblockers(db, cl, nodeid, oldvalues):
+        ''' When we resolve an issue that's a blocker, remove it from the
+            blockers list of the issue(s) it blocks.
+        '''
+        newstatus = cl.get(nodeid,'status')
+
+        # no change?
+        if oldvalues.get('status', None) == newstatus:
+            return
+
+        resolved_id = db.status.lookup('resolved')
+
+        # interesting?
+        if newstatus != resolved_id:
+            return
+
+        # yes - find all the blocked issues, if any, and remove me from
+        # their blockers list
+        issues = cl.find(blockers=nodeid)
+        for issueid in issues:
+            blockers = cl.get(issueid, 'blockers')
+            if nodeid in blockers:
+                blockers.remove(nodeid)
+                cl.set(issueid, blockers=blockers)
+
+    def init(db):
+        # might, in an obscure situation, happen in a create
+        db.issue.audit('create', blockresolution)
+        db.issue.audit('set', blockresolution)
+
+        # can only happen on a set
+        db.issue.react('set', resolveblockers)
+
+   Put the above code in a file called "blockers.py" in your tracker's
+   "detectors" directory.
+
+4. Finally, and this is an optional step, modify the tracker web page
+   URLs so they filter out issues with any blockers. You do this by
+   adding an additional filter on "blockers" for the value "-1". For
+   example, the existing "Show All" link in the "page" template (in the
+   tracker's "html" directory) looks like this::
+
+     <a href="issue?@sort=-activity&@group=priority&@filter=status&
+        @columns=id,activity,title,creator,assignedto,status&
+        status=-1,1,2,3,4,5,6,7">Show All</a><br>
+
+   modify it to add the "blockers" info to the URL (note, both the
+   "@filter" *and* "blockers" values must be specified)::
+
+     <a href="issue?@sort=-activity&@group=priority&@filter=status,blockers&
+        blockers=-1&@columns=id,activity,title,creator,assignedto,status&
+        status=-1,1,2,3,4,5,6,7">Show All</a><br>
+
+   The above examples are line-wrapped on the trailing & and should
+   be unwrapped.
+
+That's it. You should now be able to set blockers on your issues. Note
+that if you want to know whether an issue has any other issues dependent
+on it (i.e. it's in their blockers list) you can look at the journal
+history at the bottom of the issue page - look for a "link" event to
+another issue's "blockers" property.
+
+Add users to the nosy list based on the topic
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Let's say we need the ability to automatically add users to the nosy
+list based
+on the occurance of a topic. Every user should be allowed to edit their
+own list of topics for which they want to be added to the nosy list.
+
+Below, we'll show that this change can be done with minimal
+understanding of the Roundup system, using only copy and paste.
+
+This requires three changes to the tracker: a change in the database to
+allow per-user recording of the lists of topics for which he wants to
+be put on the nosy list, a change in the user view allowing them to edit
+this list of topics, and addition of an auditor which updates the nosy
+list when a topic is set.
+
+Adding the nosy topic list
+::::::::::::::::::::::::::
+
+The change to make in the database, is that for any user there should be
+a list of topics for which he wants to be put on the nosy list. Adding
+a ``Multilink`` of ``keyword`` seems to fullfill this (note that within
+the code, topics are called ``keywords``.) As such, all that has to be
+done is to add a new field to the definition of ``user`` within the
+file ``schema.py``.  We will call this new field ``nosy_keywords``, and
+the updated definition of user will be::
+
+    user = Class(db, "user", 
+                    username=String(),   password=Password(),
+                    address=String(),    realname=String(), 
+                    phone=String(),      organisation=String(),
+                    alternate_addresses=String(),
+                    queries=Multilink('query'), roles=String(),
+                    timezone=String(),
+                    nosy_keywords=Multilink('keyword'))
+ 
+Changing the user view to allow changing the nosy topic list
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+
+We want any user to be able to change the list of topics for which
+he will by default be added to the nosy list. We choose to add this
+to the user view, as is generated by the file ``html/user.item.html``.
+We can easily 
+see that the topic field in the issue view has very similar editing
+requirements as our nosy topics, both being lists of topics. As
+such, we look for Topics in ``issue.item.html``, and extract the
+associated parts from there. We add this to ``user.item.html`` at the 
+bottom of the list of viewed items (i.e. just below the 'Alternate
+E-mail addresses' in the classic template)::
+
+ <tr>
+  <th>Nosy Topics</th>
+  <td>
+  <span tal:replace="structure context/nosy_keywords/field" />
+  <span tal:replace="structure python:db.keyword.classhelp(property='nosy_keywords')" />
+  </td>
+ </tr>
+  
+
+Addition of an auditor to update the nosy list
+::::::::::::::::::::::::::::::::::::::::::::::
+
+The more difficult part is the logic to add
+the users to the nosy list when required. 
+We choose to perform this action whenever the topics on an
+item are set (this includes the creation of items).
+Here we choose to start out with a copy of the 
+``detectors/nosyreaction.py`` detector, which we copy to the file
+``detectors/nosy_keyword_reaction.py``. 
+This looks like a good start as it also adds users
+to the nosy list. A look through the code reveals that the
+``nosyreaction`` function actually sends the e-mail. 
+We don't need this. Therefore, we can change the ``init`` function to::
+
+    def init(db):
+        db.issue.audit('create', update_kw_nosy)
+        db.issue.audit('set', update_kw_nosy)
+
+After that, we rename the ``updatenosy`` function to ``update_kw_nosy``.
+The first two blocks of code in that function relate to setting
+``current`` to a combination of the old and new nosy lists. This
+functionality is left in the new auditor. The following block of
+code, which handled adding the assignedto user(s) to the nosy list in
+``updatenosy``, should be replaced by a block of code to add the
+interested users to the nosy list. We choose here to loop over all
+new topics, than looping over all users,
+and assign the user to the nosy list when the topic occurs in the user's
+``nosy_keywords``. The next part in ``updatenosy`` -- adding the author
+and/or recipients of a message to the nosy list -- is obviously not
+relevant here and is thus deleted from the new auditor. The last
+part, copying the new nosy list to ``newvalues``, can stay as is.
+This results in the following function::
+
+    def update_kw_nosy(db, cl, nodeid, newvalues):
+        '''Update the nosy list for changes to the topics
+        '''
+        # nodeid will be None if this is a new node
+        current = {}
+        if nodeid is None:
+            ok = ('new', 'yes')
+        else:
+            ok = ('yes',)
+            # old node, get the current values from the node if they haven't
+            # changed
+            if not newvalues.has_key('nosy'):
+                nosy = cl.get(nodeid, 'nosy')
+                for value in nosy:
+                    if not current.has_key(value):
+                        current[value] = 1
+
+        # if the nosy list changed in this transaction, init from the new value
+        if newvalues.has_key('nosy'):
+            nosy = newvalues.get('nosy', [])
+            for value in nosy:
+                if not db.hasnode('user', value):
+                    continue
+                if not current.has_key(value):
+                    current[value] = 1
+
+        # add users with topic in nosy_keywords to the nosy list
+        if newvalues.has_key('topic') and newvalues['topic'] is not None:
+            topic_ids = newvalues['topic']
+            for topic in topic_ids:
+                # loop over all users,
+                # and assign user to nosy when topic in nosy_keywords
+                for user_id in db.user.list():
+                    nosy_kw = db.user.get(user_id, "nosy_keywords")
+                    found = 0
+                    for kw in nosy_kw:
+                        if kw == topic:
+                            found = 1
+                    if found:
+                        current[user_id] = 1
+
+        # that's it, save off the new nosy list
+        newvalues['nosy'] = current.keys()
+
+These two function are the only ones needed in the file.
+
+TODO: update this example to use the ``find()`` Class method.
+
+Caveats
+:::::::
+
+A few problems with the design here can be noted:
+
+Multiple additions
+    When a user, after automatic selection, is manually removed
+    from the nosy list, he is added to the nosy list again when the
+    topic list of the issue is updated. A better design might be
+    to only check which topics are new compared to the old list
+    of topics, and only add users when they have indicated
+    interest on a new topic.
+
+    The code could also be changed to only trigger on the ``create()``
+    event, rather than also on the ``set()`` event, thus only setting
+    the nosy list when the issue is created.
+
+Scalability
+    In the auditor, there is a loop over all users. For a site with
+    only few users this will pose no serious problem; however, with
+    many users this will be a serious performance bottleneck.
+    A way out would be to link from the topics to the users who
+    selected these topics as nosy topics. This will eliminate the
+    loop over all users.
+
+Changes to Security and Permissions
+-----------------------------------
+
+Restricting the list of users that are assignable to a task
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+1. In your tracker's ``schema.py``, create a new Role, say "Developer"::
+
+     db.security.addRole(name='Developer', description='A developer')
+
+2. Just after that, create a new Permission, say "Fixer", specific to
+   "issue"::
+
+     p = db.security.addPermission(name='Fixer', klass='issue',
+         description='User is allowed to be assigned to fix issues')
+
+3. Then assign the new Permission to your "Developer" Role::
+
+     db.security.addPermissionToRole('Developer', p)
+
+4. In the issue item edit page (``html/issue.item.html`` in your tracker
+   directory), use the new Permission in restricting the "assignedto"
+   list::
+
+    <select name="assignedto">
+     <option value="-1">- no selection -</option>
+     <tal:block tal:repeat="user db/user/list">
+     <option tal:condition="python:user.hasPermission(
+                                'Fixer', context._classname)"
+             tal:attributes="
+                value user/id;
+                selected python:user.id == context.assignedto"
+             tal:content="user/realname"></option>
+     </tal:block>
+    </select>
+
+For extra security, you may wish to setup an auditor to enforce the
+Permission requirement (install this as ``assignedtoFixer.py`` in your
+tracker ``detectors`` directory)::
+
+  def assignedtoMustBeFixer(db, cl, nodeid, newvalues):
+      ''' Ensure the assignedto value in newvalues is used with the
+          Fixer Permission
+      '''
+      if not newvalues.has_key('assignedto'):
+          # don't care
+          return
+  
+      # get the userid
+      userid = newvalues['assignedto']
+      if not db.security.hasPermission('Fixer', userid, cl.classname):
+          raise ValueError, 'You do not have permission to edit %s'%cl.classname
+
+  def init(db):
+      db.issue.audit('set', assignedtoMustBeFixer)
+      db.issue.audit('create', assignedtoMustBeFixer)
+
+So now, if an edit action attempts to set "assignedto" to a user that
+doesn't have the "Fixer" Permission, the error will be raised.
+
+
+Users may only edit their issues
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+In this case, users registering themselves are granted Provisional
+access, meaning they
+have access to edit the issues they submit, but not others. We create a new
+Role called "Provisional User" which is granted to newly-registered users,
+and has limited access. One of the Permissions they have is the new "Edit
+Own" on issues (regular users have "Edit".)
+
+First up, we create the new Role and Permission structure in
+``schema.py``::
+
+    #
+    # New users not approved by the admin
+    #
+    db.security.addRole(name='Provisional User',
+        description='New user registered via web or email')
+
+    # These users need to be able to view and create issues but only edit
+    # and view their own
+    db.security.addPermissionToRole('Provisional User', 'Create', 'issue')
+    def own_issue(db, userid, itemid):
+        '''Determine whether the userid matches the creator of the issue.'''
+        return userid == db.issue.get(itemid, 'creator')
+    p = db.security.addPermission(name='Edit', klass='issue',
+        check=own_issue, description='Can only edit own issues')
+    db.security.addPermissionToRole('Provisional User', p)
+    p = db.security.addPermission(name='View', klass='issue',
+        check=own_issue, description='Can only view own issues')
+    db.security.addPermissionToRole('Provisional User', p)
+
+    # Assign the Permissions for issue-related classes
+    for cl in 'file', 'msg', 'query', 'keyword':
+        db.security.addPermissionToRole('Provisional User', 'View', cl)
+        db.security.addPermissionToRole('Provisional User', 'Edit', cl)
+        db.security.addPermissionToRole('Provisional User', 'Create', cl)
+    for cl in 'priority', 'status':
+        db.security.addPermissionToRole('Provisional User', 'View', cl)
+
+    # and give the new users access to the web and email interface
+    db.security.addPermissionToRole('Provisional User', 'Web Access')
+    db.security.addPermissionToRole('Provisional User', 'Email Access')
+
+    # make sure they can view & edit their own user record
+    def own_record(db, userid, itemid):
+        '''Determine whether the userid matches the item being accessed.'''
+        return userid == itemid
+    p = db.security.addPermission(name='View', klass='user', check=own_record,
+        description="User is allowed to view their own user details")
+    db.security.addPermissionToRole('Provisional User', p)
+    p = db.security.addPermission(name='Edit', klass='user', check=own_record,
+        description="User is allowed to edit their own user details")
+    db.security.addPermissionToRole('Provisional User', p)
+
+Then, in ``config.ini``, we change the Role assigned to newly-registered
+users, replacing the existing ``'User'`` values::
+
+    [main]
+    ...
+    new_web_user_roles = 'Provisional User'
+    new_email_user_roles = 'Provisional User'
+
+
+All users may only view and edit issues, files and messages they create
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Replace the standard "classic" tracker View and Edit Permission assignments
+for the "issue", "file" and "msg" classes with the following::
+
+    def checker(klass):
+        def check(db, userid, itemid, klass=klass):
+            return db.getclass(klass).get(itemid, 'creator') == userid
+        return check
+    for cl in 'issue', 'file', 'msg':
+        p = db.security.addPermission(name='View', klass=cl,
+            check=checker(cl))
+        db.security.addPermissionToRole('User', p)
+        p = db.security.addPermission(name='Edit', klass=cl,
+            check=checker(cl))
+        db.security.addPermissionToRole('User', p)
+        db.security.addPermissionToRole('User', 'Create', cl)
+
+
+
+Changes to the Web User Interface
+---------------------------------
+
+Adding action links to the index page
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Add a column to the ``item.index.html`` template.
+
+Resolving the issue::
+
+  <a tal:attributes="href
+     string:issue${i/id}?:status=resolved&:action=edit">resolve</a>
+
+"Take" the issue::
+
+  <a tal:attributes="href
+     string:issue${i/id}?:assignedto=${request/user/id}&:action=edit">take</a>
+
+... and so on.
+
+Colouring the rows in the issue index according to priority
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+A simple ``tal:attributes`` statement will do the bulk of the work here. In
+the ``issue.index.html`` template, add this to the ``<tr>`` that
+displays the rows of data::
+
+   <tr tal:attributes="class string:priority-${i/priority/plain}">
+
+and then in your stylesheet (``style.css``) specify the colouring for the
+different priorities, as follows::
+
+   tr.priority-critical td {
+       background-color: red;
+   }
+
+   tr.priority-urgent td {
+       background-color: orange;
+   }
+
+and so on, with far less offensive colours :)
+
+Editing multiple items in an index view
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+To edit the status of all items in the item index view, edit the
+``issue.item.html``:
+
+1. add a form around the listing table (separate from the existing
+   index-page form), so at the top it reads::
+
+    <form method="POST" tal:attributes="action request/classname">
+     <table class="list">
+
+   and at the bottom of that table::
+
+     </table>
+    </form
+
+   making sure you match the ``</table>`` from the list table, not the
+   navigation table or the subsequent form table.
+
+2. in the display for the issue property, change::
+
+    <td tal:condition="request/show/status"
+        tal:content="python:i.status.plain() or default">&nbsp;</td>
+
+   to::
+
+    <td tal:condition="request/show/status"
+        tal:content="structure i/status/field">&nbsp;</td>
+
+   this will result in an edit field for the status property.
+
+3. after the ``tal:block`` which lists the index items (marked by
+   ``tal:repeat="i batch"``) add a new table row::
+
+    <tr>
+     <td tal:attributes="colspan python:len(request.columns)">
+      <input type="submit" value=" Save Changes ">
+      <input type="hidden" name="@action" value="edit">
+      <tal:block replace="structure request/indexargs_form" />
+     </td>
+    </tr>
+
+   which gives us a submit button, indicates that we are performing an edit
+   on any changed statuses. The final ``tal:block`` will make sure that the
+   current index view parameters (filtering, columns, etc) will be used in 
+   rendering the next page (the results of the editing).
+
+
+Displaying only message summaries in the issue display
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Alter the ``issue.item`` template section for messages to::
+
+ <table class="messages" tal:condition="context/messages">
+  <tr><th colspan="5" class="header">Messages</th></tr>
+  <tr tal:repeat="msg context/messages">
+   <td><a tal:attributes="href string:msg${msg/id}"
+          tal:content="string:msg${msg/id}"></a></td>
+   <td tal:content="msg/author">author</td>
+   <td class="date" tal:content="msg/date/pretty">date</td>
+   <td tal:content="msg/summary">summary</td>
+   <td>
+    <a tal:attributes="href string:?@remove at messages=${msg/id}&@action=edit">
+    remove</a>
+   </td>
+  </tr>
+ </table>
+
+
+Enabling display of either message summaries or the entire messages
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+This is pretty simple - all we need to do is copy the code from the
+example `displaying only message summaries in the issue display`_ into
+our template alongside the summary display, and then introduce a switch
+that shows either the one or the other. We'll use a new form variable,
+``@whole_messages`` to achieve this::
+
+ <table class="messages" tal:condition="context/messages">
+  <tal:block tal:condition="not:request/form/@whole_messages/value | python:0">
+   <tr><th colspan="3" class="header">Messages</th>
+       <th colspan="2" class="header">
+         <a href="?@whole_messages=yes">show entire messages</a>
+       </th>
+   </tr>
+   <tr tal:repeat="msg context/messages">
+    <td><a tal:attributes="href string:msg${msg/id}"
+           tal:content="string:msg${msg/id}"></a></td>
+    <td tal:content="msg/author">author</td>
+    <td class="date" tal:content="msg/date/pretty">date</td>
+    <td tal:content="msg/summary">summary</td>
+    <td>
+     <a tal:attributes="href string:?@remove at messages=${msg/id}&@action=edit">remove</a>
+    </td>
+   </tr>
+  </tal:block>
+
+  <tal:block tal:condition="request/form/@whole_messages/value | python:0">
+   <tr><th colspan="2" class="header">Messages</th>
+       <th class="header">
+         <a href="?@whole_messages=">show only summaries</a>
+       </th>
+   </tr>
+   <tal:block tal:repeat="msg context/messages">
+    <tr>
+     <th tal:content="msg/author">author</th>
+     <th class="date" tal:content="msg/date/pretty">date</th>
+     <th style="text-align: right">
+      (<a tal:attributes="href string:?@remove at messages=${msg/id}&@action=edit">remove</a>)
+     </th>
+    </tr>
+    <tr><td colspan="3" tal:content="msg/content"></td></tr>
+   </tal:block>
+  </tal:block>
+ </table>
+
+
+Setting up a "wizard" (or "druid") for controlled adding of issues
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+1. Set up the page templates you wish to use for data input. My wizard
+   is going to be a two-step process: first figuring out what category
+   of issue the user is submitting, and then getting details specific to
+   that category. The first page includes a table of help, explaining
+   what the category names mean, and then the core of the form::
+
+    <form method="POST" onSubmit="return submit_once()"
+          enctype="multipart/form-data">
+      <input type="hidden" name="@template" value="add_page1">
+      <input type="hidden" name="@action" value="page1_submit">
+
+      <strong>Category:</strong>
+      <tal:block tal:replace="structure context/category/menu" />
+      <input type="submit" value="Continue">
+    </form>
+
+   The next page has the usual issue entry information, with the
+   addition of the following form fragments::
+
+    <form method="POST" onSubmit="return submit_once()"
+          enctype="multipart/form-data"
+          tal:condition="context/is_edit_ok"
+          tal:define="cat request/form/category/value">
+
+      <input type="hidden" name="@template" value="add_page2">
+      <input type="hidden" name="@required" value="title">
+      <input type="hidden" name="category" tal:attributes="value cat">
+       .
+       .
+       .
+    </form>
+
+   Note that later in the form, I use the value of "cat" to decide which
+   form elements should be displayed. For example::
+
+    <tal:block tal:condition="python:cat in '6 10 13 14 15 16 17'.split()">
+     <tr>
+      <th>Operating System</th>
+      <td tal:content="structure context/os/field"></td>
+     </tr>
+     <tr>
+      <th>Web Browser</th>
+      <td tal:content="structure context/browser/field"></td>
+     </tr>
+    </tal:block>
+
+   ... the above section will only be displayed if the category is one
+   of 6, 10, 13, 14, 15, 16 or 17.
+
+3. Determine what actions need to be taken between the pages - these are
+   usually to validate user choices and determine what page is next. Now encode
+   those actions in a new ``Action`` class (see `defining new web actions`_)::
+
+    from roundup.cgi.actions import Action
+
+    class Page1SubmitAction(Action):
+        def handle(self):
+            ''' Verify that the user has selected a category, and then move
+                on to page 2.
+            '''
+            category = self.form['category'].value
+            if category == '-1':
+                self.error_message.append('You must select a category of report')
+                return
+            # everything's ok, move on to the next page
+            self.template = 'add_page2'
+
+    def init(instance):
+        instance.registerAction('page1_submit', Page1SubmitAction)
+
+4. Use the usual "new" action as the ``@action`` on the final page, and
+   you're done (the standard context/submit method can do this for you).
+
+
+Debugging Trackers
+==================
+
+There are three switches in tracker configs that turn on debugging in
+Roundup:
+
+1. web :: debug
+2. mail :: debug
+3. logging :: level
+
+See the config.ini file or the `tracker configuration`_ section above for
+more information.
+
+Additionally, the ``roundup-server.py`` script has its own debugging mode
+in which it reloads edited templates immediately when they are changed,
+rather than requiring a web server restart.
+
+
+-------------------
+
+Back to `Table of Contents`_
+
+.. _`Table of Contents`: index.html
+.. _`design documentation`: design.html
+.. _`admin guide`: admin_guide.html
+

Added: tracker/vendor/roundup/current/doc/debugging.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/debugging.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,31 @@
+Debugging Flags
+---------------
+
+Roundup uses a number of debugging environment variables to help you
+figure out what the heck it's doing. 
+
+HYPERDBDEBUG 
+============
+
+This environment variable should be set to a filename - the hyperdb will
+write debugging information for various events (including, for instance,
+the SQL used).
+
+This is only obeyed when python is _not_ running in -O mode. 
+
+HYPERDBTRACE
+============
+
+This environment variable should be set to a filename - the hyperdb will
+write a timestamp entry for various events. This appears to be suffering
+rather extreme bit-rot and may go away soon.
+
+This is only obeyed when python is _not_ running in -O mode. 
+
+SENDMAILDEBUG
+=============
+
+Set to a filename and roundup will write a copy of each email message
+that it sends to that file. This environment variable is independent of
+the python -O flag.
+

Added: tracker/vendor/roundup/current/doc/default.css
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/default.css	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,239 @@
+/*
+:Author: David Goodger
+:Contact: goodger at users.sourceforge.net
+:date: $Date: 2004/06/09 00:25:32 $
+:version: $Revision: 1.13 $
+:copyright: This stylesheet has been placed in the public domain.
+
+Default cascading style sheet for the HTML output of Docutils.
+*/
+
+a.target {
+  color: blue }
+
+a.toc-backref {
+  text-decoration: none ;
+  color: black }
+
+dd {
+  margin-bottom: 0.5em }
+
+div.abstract {
+  margin: 2em 5em }
+
+div.abstract p.topic-title {
+  font-weight: bold ;
+  text-align: center }
+
+div.attention, div.caution, div.danger, div.error,
+div.important, div.tip, div.warning {
+  margin: 2em ;
+  border: medium outset ;
+  padding: 1em }
+
+div.hint, div.note {
+  font-size: 80%;
+  float: right;
+  width: 15em;
+  margin: 0.5em;
+  margin-left: 1em ;
+  border: solid #aaa;
+  background: #eee;
+  padding: 1em;
+}
+
+div.attention p.admonition-title, div.caution p.admonition-title,
+div.danger p.admonition-title, div.error p.admonition-title,
+div.warning p.admonition-title {
+  color: red ;
+  font-weight: bold ;
+  font-family: sans-serif }
+
+div.hint p.admonition-title, div.important p.admonition-title,
+div.note p.admonition-title, div.tip p.admonition-title {
+  font-weight: bold ;
+  font-family: sans-serif }
+
+div.dedication {
+  margin: 2em 5em ;
+  text-align: center ;
+  font-style: italic }
+
+div.dedication p.topic-title {
+  font-weight: bold ;
+  font-style: normal }
+
+div.figure {
+  margin-left: 2em }
+
+div.footer, div.header {
+  font-size: smaller }
+
+div.system-messages {
+  margin: 5em }
+
+div.system-messages h1 {
+  color: red }
+
+div.system-message {
+  border: medium outset ;
+  padding: 1em }
+
+div.system-message p.system-message-title {
+  color: red ;
+  font-weight: bold }
+
+div.topic {
+  margin: 2em }
+
+h1 {
+  margin-top: 2em;
+  text-decoration: underline;
+}
+
+h1.title {
+  text-align: center;
+  margin-top: .5em;
+}
+
+h2.subtitle {
+  text-align: center }
+
+hr {
+  width: 75% }
+
+ol.simple, ul.simple {
+  margin-top: 0;
+  margin-bottom: 1em }
+
+ol.arabic {
+  list-style: decimal }
+
+ol.loweralpha {
+  list-style: lower-alpha }
+
+ol.upperalpha {
+  list-style: upper-alpha }
+
+ol.lowerroman {
+  list-style: lower-roman }
+
+ol.upperroman {
+  list-style: upper-roman }
+
+p.caption {
+  font-style: italic }
+
+p.credits {
+  font-style: italic ;
+  font-size: smaller }
+
+p.first {
+  margin-top: 0 }
+
+p.label {
+  white-space: nowrap }
+
+p.topic-title {
+  font-weight: bold }
+
+pre.address {
+  margin-bottom: 0 ;
+  margin-top: 0 ;
+  font-family: serif ;
+  font-size: 100% }
+
+pre.line-block {
+  font-family: serif ;
+  font-size: 100% }
+
+pre.literal-block, pre.doctest-block {
+  margin-left: 2em ;
+  margin-right: 2em ;
+  background-color: #eeeeee }
+
+span.classifier {
+  font-family: sans-serif ;
+  font-style: oblique }
+
+span.classifier-delimiter {
+  font-family: sans-serif ;
+  font-weight: bold }
+
+span.field-argument {
+  font-style: italic }
+
+span.interpreted {
+  font-family: sans-serif }
+
+span.option-argument {
+  font-style: italic }
+
+span.pre {
+  white-space: pre }
+
+span.problematic {
+  color: red }
+
+table {
+  margin-top: 0.5em ;
+  margin-bottom: 0.5em ;
+  }
+
+table.citation {
+  border-top: 0;
+  border-bottom: 0;
+  border-right: 0;
+  border-left: solid thin gray ;
+  padding-left: 0.5ex }
+
+table.docinfo {
+  margin: 2em 4em }
+
+table.footnote {
+  border-left: solid thin black ;
+  padding-left: 0.5ex }
+
+td, th {
+  padding-left: 0.5em ;
+  padding-right: 0.5em ;
+  vertical-align: baseline;
+}
+
+table.table {
+  border-spacing: 0px;
+  border-collapse: separate;
+}
+table.table td {
+  text-align: left;
+  border: solid thin gray;
+}
+
+table.table th {
+  text-align: left;
+  border: solid thin gray;
+}
+
+td > p:first-child, th > p:first-child {
+  margin-top: 0em }
+
+th.docinfo-name {
+  font-weight: bold ;
+  text-align: right }
+
+th.field-name {
+  font-weight: bold ;
+  text-align: right }
+
+h1 tt, h2 tt, h3 tt, h4 tt, h5 tt, h6 tt {
+  font-size: 100% }
+
+tt {
+  background-color: #eeeeee }
+
+tt.literal span.pre {
+  background-color: #eeeeee
+}
+
+ul.auto-toc {
+  list-style-type: none }

Added: tracker/vendor/roundup/current/doc/design.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/design.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,1639 @@
+========================================================
+Roundup - An Issue-Tracking System for Knowledge Workers
+========================================================
+
+:Authors: Ka-Ping Yee (original), Richard Jones (implementation)
+
+.. contents::
+
+Introduction
+---------------
+
+This document presents a description of the components of the Roundup
+system and specifies their interfaces and behaviour in sufficient detail
+to guide an implementation. For the philosophy and rationale behind the
+Roundup design, see the first-round Software Carpentry `submission for
+Roundup`__. This document fleshes out that design as well as specifying
+interfaces so that the components can be developed separately.
+
+__ spec.html
+
+
+The Layer Cake
+-----------------
+
+Lots of software design documents come with a picture of a cake.
+Everybody seems to like them.  I also like cakes (i think they are
+tasty).  So I, too, shall include a picture of a cake here::
+
+     ________________________________________________________________
+    | E-mail Client |  Web Browser  |  Detector Scripts  |   Shell   |
+    |---------------+---------------+--------------------+-----------|
+    |  E-mail User  |   Web User    |     Detector       |  Command  | 
+    |----------------------------------------------------------------|
+    |                    Roundup Database Layer                      |
+    |----------------------------------------------------------------|
+    |                     Hyperdatabase Layer                        |
+    |----------------------------------------------------------------|
+    |                        Storage Layer                           |
+     ----------------------------------------------------------------
+
+The colourful parts of the cake are part of our system; the faint grey
+parts of the cake are external components.
+
+I will now proceed to forgo all table manners and eat from the bottom of
+the cake to the top.  You may want to stand back a bit so you don't get
+covered in crumbs.
+
+
+Hyperdatabase
+-------------
+
+The lowest-level component to be implemented is the hyperdatabase. The
+hyperdatabase is a flexible data store that can hold configurable data
+in records which we call items.
+
+The hyperdatabase is implemented on top of the storage layer, an
+external module for storing its data. The "batteries-includes" distribution
+implements the hyperdatabase on the standard anydbm module.  The storage
+layer could be a third-party RDBMS; for a low-maintenance solution,
+implementing the hyperdatabase on the SQLite RDBMS is suggested.
+
+
+Dates and Date Arithmetic
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Before we get into the hyperdatabase itself, we need a way of handling
+dates.  The hyperdatabase module provides Timestamp objects for
+representing date-and-time stamps and Interval objects for representing
+date-and-time intervals.
+
+As strings, date-and-time stamps are specified with the date in
+international standard format (``yyyy-mm-dd``) joined to the time
+(``hh:mm:ss``) by a period "``.``".  Dates in this form can be easily
+compared and are fairly readable when printed.  An example of a valid
+stamp is "``2000-06-24.13:03:59``". We'll call this the "full date
+format".  When Timestamp objects are printed as strings, they appear in
+the full date format with the time always given in GMT.  The full date
+format is always exactly 19 characters long.
+
+For user input, some partial forms are also permitted: the whole time or
+just the seconds may be omitted; and the whole date may be omitted or
+just the year may be omitted.  If the time is given, the time is
+interpreted in the user's local time zone. The Date constructor takes
+care of these conversions. In the following examples, suppose that
+``yyyy`` is the current year, ``mm`` is the current month, and ``dd`` is
+the current day of the month; and suppose that the user is on Eastern
+Standard Time.
+
+-   "2000-04-17" means <Date 2000-04-17.00:00:00>
+-   "01-25" means <Date yyyy-01-25.00:00:00>
+-   "2000-04-17.03:45" means <Date 2000-04-17.08:45:00>
+-   "08-13.22:13" means <Date yyyy-08-14.03:13:00>
+-   "11-07.09:32:43" means <Date yyyy-11-07.14:32:43>
+-   "14:25" means
+-   <Date yyyy-mm-dd.19:25:00>
+-   "8:47:11" means
+-   <Date yyyy-mm-dd.13:47:11>
+-   the special date "." means "right now"
+
+
+Date intervals are specified using the suffixes "y", "m", and "d".  The
+suffix "w" (for "week") means 7 days. Time intervals are specified in
+hh:mm:ss format (the seconds may be omitted, but the hours and minutes
+may not).
+
+-   "3y" means three years
+-   "2y 1m" means two years and one month
+-   "1m 25d" means one month and 25 days
+-   "2w 3d" means two weeks and three days
+-   "1d 2:50" means one day, two hours, and 50 minutes
+-   "14:00" means 14 hours
+-   "0:04:33" means four minutes and 33 seconds
+
+
+The Date class should understand simple date expressions of the form
+*stamp* ``+`` *interval* and *stamp* ``-`` *interval*. When adding or
+subtracting intervals involving months or years, the components are
+handled separately.  For example, when evaluating "``2000-06-25 + 1m
+10d``", we first add one month to get 2000-07-25, then add 10 days to
+get 2000-08-04 (rather than trying to decide whether 1m 10d means 38 or
+40 or 41 days).
+
+Here is an outline of the Date and Interval classes::
+
+    class Date:
+        def __init__(self, spec, offset):
+            """Construct a date given a specification and a time zone
+            offset.
+
+            'spec' is a full date or a partial form, with an optional
+            added or subtracted interval.  'offset' is the local time
+            zone offset from GMT in hours.
+            """
+
+        def __add__(self, interval):
+            """Add an interval to this date to produce another date."""
+
+        def __sub__(self, interval):
+            """Subtract an interval from this date to produce another
+            date.
+            """
+
+        def __cmp__(self, other):
+            """Compare this date to another date."""
+
+        def __str__(self):
+            """Return this date as a string in the yyyy-mm-dd.hh:mm:ss
+            format.
+            """
+
+        def local(self, offset):
+            """Return this date as yyyy-mm-dd.hh:mm:ss in a local time
+            zone.
+            """
+
+    class Interval:
+        def __init__(self, spec):
+            """Construct an interval given a specification."""
+
+        def __cmp__(self, other):
+            """Compare this interval to another interval."""
+            
+        def __str__(self):
+            """Return this interval as a string."""
+
+
+
+Here are some examples of how these classes would behave in practice.
+For the following examples, assume that we are on Eastern Standard Time
+and the current local time is 19:34:02 on 25 June 2000::
+
+    >>> Date(".")
+    <Date 2000-06-26.00:34:02>
+    >>> _.local(-5)
+    "2000-06-25.19:34:02"
+    >>> Date(". + 2d")
+    <Date 2000-06-28.00:34:02>
+    >>> Date("1997-04-17", -5)
+    <Date 1997-04-17.00:00:00>
+    >>> Date("01-25", -5)
+    <Date 2000-01-25.00:00:00>
+    >>> Date("08-13.22:13", -5)
+    <Date 2000-08-14.03:13:00>
+    >>> Date("14:25", -5)
+    <Date 2000-06-25.19:25:00>
+    >>> Interval("  3w  1  d  2:00")
+    <Interval 22d 2:00>
+    >>> Date(". + 2d") - Interval("3w")
+    <Date 2000-06-07.00:34:02>
+
+
+Items and Classes
+~~~~~~~~~~~~~~~~~
+
+Items contain data in properties.  To Python, these properties are
+presented as the key-value pairs of a dictionary. Each item belongs to a
+class which defines the names and types of its properties.  The database
+permits the creation and modification of classes as well as items.
+
+
+Identifiers and Designators
+~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Each item has a numeric identifier which is unique among items in its
+class.  The items are numbered sequentially within each class in order
+of creation, starting from 1. The designator for an item is a way to
+identify an item in the database, and consists of the name of the item's
+class concatenated with the item's numeric identifier.
+
+For example, if "spam" and "eggs" are classes, the first item created in
+class "spam" has id 1 and designator "spam1". The first item created in
+class "eggs" also has id 1 but has the distinct designator "eggs1". Item
+designators are conventionally enclosed in square brackets when
+mentioned in plain text.  This permits a casual mention of, say,
+"[patch37]" in an e-mail message to be turned into an active hyperlink.
+
+
+Property Names and Types
+~~~~~~~~~~~~~~~~~~~~~~~~
+
+Property names must begin with a letter.
+
+A property may be one of five basic types:
+
+- String properties are for storing arbitrary-length strings.
+
+- Boolean properties are for storing true/false, or yes/no values.
+
+- Number properties are for storing numeric values.
+
+- Date properties store date-and-time stamps. Their values are Timestamp
+  objects.
+
+- A Link property refers to a single other item selected from a
+  specified class.  The class is part of the property; the value is an
+  integer, the id of the chosen item.
+
+- A Multilink property refers to possibly many items in a specified
+  class.  The value is a list of integers.
+
+*None* is also a permitted value for any of these property types.  An
+attempt to store None into a Multilink property stores an empty list.
+
+A property that is not specified will return as None from a *get*
+operation.
+
+
+Hyperdb Interface Specification
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+TODO: replace the Interface Specifications with links to the pydoc
+
+The hyperdb module provides property objects to designate the different
+kinds of properties.  These objects are used when specifying what
+properties belong in classes::
+
+    class String:
+        def __init__(self, indexme='no'):
+            """An object designating a String property."""
+
+    class Boolean:
+        def __init__(self):
+            """An object designating a Boolean property."""
+
+    class Number:
+        def __init__(self):
+            """An object designating a Number property."""
+
+    class Date:
+        def __init__(self):
+            """An object designating a Date property."""
+
+    class Link:
+        def __init__(self, classname, do_journal='yes'):
+            """An object designating a Link property that links to
+            items in a specified class.
+
+            If the do_journal argument is not 'yes' then changes to
+            the property are not journalled in the linked item.
+            """
+
+    class Multilink:
+        def __init__(self, classname, do_journal='yes'):
+            """An object designating a Multilink property that links
+            to items in a specified class.
+
+            If the do_journal argument is not 'yes' then changes to
+            the property are not journalled in the linked item(s).
+            """
+
+
+Here is the interface provided by the hyperdatabase::
+
+    class Database:
+        """A database for storing records containing flexible data
+        types.
+        """
+
+        def __init__(self, config, journaltag=None):
+            """Open a hyperdatabase given a specifier to some storage.
+
+            The 'storagelocator' is obtained from config.DATABASE. The
+            meaning of 'storagelocator' depends on the particular
+            implementation of the hyperdatabase.  It could be a file
+            name, a directory path, a socket descriptor for a connection
+            to a database over the network, etc.
+
+            The 'journaltag' is a token that will be attached to the
+            journal entries for any edits done on the database.  If
+            'journaltag' is None, the database is opened in read-only
+            mode: the Class.create(), Class.set(), Class.retire(), and
+            Class.restore() methods are disabled.
+            """
+
+        def __getattr__(self, classname):
+            """A convenient way of calling self.getclass(classname)."""
+
+        def getclasses(self):
+            """Return a list of the names of all existing classes."""
+
+        def getclass(self, classname):
+            """Get the Class object representing a particular class.
+
+            If 'classname' is not a valid class name, a KeyError is
+            raised.
+            """
+
+    class Class:
+        """The handle to a particular class of items in a hyperdatabase.
+        """
+
+        def __init__(self, db, classname, **properties):
+            """Create a new class with a given name and property
+            specification.
+
+            'classname' must not collide with the name of an existing
+            class, or a ValueError is raised.  The keyword arguments in
+            'properties' must map names to property objects, or a
+            TypeError is raised.
+
+            A proxied reference to the database is available as the
+            'db' attribute on instances. For example, in
+            'IssueClass.send_message', the following is used to lookup
+            users, messages and files::
+
+                users = self.db.user
+                messages = self.db.msg
+                files = self.db.file
+            """
+
+        # Editing items:
+
+        def create(self, **propvalues):
+            """Create a new item of this class and return its id.
+
+            The keyword arguments in 'propvalues' map property names to
+            values. The values of arguments must be acceptable for the
+            types of their corresponding properties or a TypeError is
+            raised.  If this class has a key property, it must be
+            present and its value must not collide with other key
+            strings or a ValueError is raised.  Any other properties on
+            this class that are missing from the 'propvalues' dictionary
+            are set to None.  If an id in a link or multilink property
+            does not refer to a valid item, an IndexError is raised.
+            """
+
+        def get(self, itemid, propname):
+            """Get the value of a property on an existing item of this
+            class.
+
+            'itemid' must be the id of an existing item of this class or
+            an IndexError is raised.  'propname' must be the name of a
+            property of this class or a KeyError is raised.
+            """
+
+        def set(self, itemid, **propvalues):
+            """Modify a property on an existing item of this class.
+            
+            'itemid' must be the id of an existing item of this class or
+            an IndexError is raised.  Each key in 'propvalues' must be
+            the name of a property of this class or a KeyError is
+            raised.  All values in 'propvalues' must be acceptable types
+            for their corresponding properties or a TypeError is raised.
+            If the value of the key property is set, it must not collide
+            with other key strings or a ValueError is raised.  If the
+            value of a Link or Multilink property contains an invalid
+            item id, a ValueError is raised.
+            """
+
+        def retire(self, itemid):
+            """Retire an item.
+            
+            The properties on the item remain available from the get()
+            method, and the item's id is never reused.  Retired items
+            are not returned by the find(), list(), or lookup() methods,
+            and other items may reuse the values of their key
+            properties.
+            """
+
+        def restore(self, nodeid):
+        '''Restore a retired node.
+
+        Make node available for all operations like it was before
+        retirement.
+        '''
+
+        def history(self, itemid):
+            """Retrieve the journal of edits on a particular item.
+
+            'itemid' must be the id of an existing item of this class or
+            an IndexError is raised.
+
+            The returned list contains tuples of the form
+
+                (date, tag, action, params)
+
+            'date' is a Timestamp object specifying the time of the
+            change and 'tag' is the journaltag specified when the
+            database was opened. 'action' may be:
+
+                'create' or 'set' -- 'params' is a dictionary of
+                    property values
+                'link' or 'unlink' -- 'params' is (classname, itemid,
+                    propname)
+                'retire' -- 'params' is None
+            """
+
+        # Locating items:
+
+        def setkey(self, propname):
+            """Select a String property of this class to be the key
+            property.
+
+            'propname' must be the name of a String property of this
+            class or None, or a TypeError is raised.  The values of the
+            key property on all existing items must be unique or a
+            ValueError is raised.
+            """
+
+        def getkey(self):
+            """Return the name of the key property for this class or
+            None.
+            """
+
+        def lookup(self, keyvalue):
+            """Locate a particular item by its key property and return
+            its id.
+
+            If this class has no key property, a TypeError is raised.
+            If the 'keyvalue' matches one of the values for the key
+            property among the items in this class, the matching item's
+            id is returned; otherwise a KeyError is raised.
+            """
+
+        def find(self, **propspec):
+            """Get the ids of items in this class which link to the
+            given items.
+
+            'propspec' consists of keyword args propname=itemid or
+                       propname={<itemid 1>:1, <itemid 2>: 1, ...}
+            'propname' must be the name of a property in this class,
+                       or a KeyError is raised.  That property must
+                       be a Link or Multilink property, or a TypeError
+                       is raised.
+
+            Any item in this class whose 'propname' property links to
+            any of the itemids will be returned. Examples::
+
+                db.issue.find(messages='1')
+                db.issue.find(messages={'1':1,'3':1}, files={'7':1})
+            """
+
+        def filter(self, search_matches, filterspec, sort, group):
+            """ Return a list of the ids of the active items in this
+            class that match the 'filter' spec, sorted by the group spec
+            and then the sort spec.
+            """
+
+        def list(self):
+            """Return a list of the ids of the active items in this
+            class.
+            """
+
+        def count(self):
+            """Get the number of items in this class.
+
+            If the returned integer is 'numitems', the ids of all the
+            items in this class run from 1 to numitems, and numitems+1
+            will be the id of the next item to be created in this class.
+            """
+
+        # Manipulating properties:
+
+        def getprops(self):
+            """Return a dictionary mapping property names to property
+            objects.
+            """
+
+        def addprop(self, **properties):
+            """Add properties to this class.
+
+            The keyword arguments in 'properties' must map names to
+            property objects, or a TypeError is raised.  None of the
+            keys in 'properties' may collide with the names of existing
+            properties, or a ValueError is raised before any properties
+            have been added.
+            """
+
+        def getitem(self, itemid, cache=1):
+            """ Return a Item convenience wrapper for the item.
+
+            'itemid' must be the id of an existing item of this class or
+            an IndexError is raised.
+
+            'cache' indicates whether the transaction cache should be
+            queried for the item. If the item has been modified and you
+            need to determine what its values prior to modification are,
+            you need to set cache=0.
+            """
+
+    class Item:
+        """ A convenience wrapper for the given item. It provides a
+        mapping interface to a single item's properties
+        """
+
+Hyperdatabase Implementations
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Hyperdatabase implementations exist to create the interface described in
+the `hyperdb interface specification`_ over an existing storage
+mechanism. Examples are relational databases, \*dbm key-value databases,
+and so on.
+
+Several implementations are provided - they belong in the
+``roundup.backends`` package.
+
+
+Application Example
+~~~~~~~~~~~~~~~~~~~
+
+Here is an example of how the hyperdatabase module would work in
+practice::
+
+    >>> import hyperdb
+    >>> db = hyperdb.Database("foo.db", "ping")
+    >>> db
+    <hyperdb.Database "foo.db" opened by "ping">
+    >>> hyperdb.Class(db, "status", name=hyperdb.String())
+    <hyperdb.Class "status">
+    >>> _.setkey("name")
+    >>> db.status.create(name="unread")
+    1
+    >>> db.status.create(name="in-progress")
+    2
+    >>> db.status.create(name="testing")
+    3
+    >>> db.status.create(name="resolved")
+    4
+    >>> db.status.count()
+    4
+    >>> db.status.list()
+    [1, 2, 3, 4]
+    >>> db.status.lookup("in-progress")
+    2
+    >>> db.status.retire(3)
+    >>> db.status.list()
+    [1, 2, 4]
+    >>> hyperdb.Class(db, "issue", title=hyperdb.String(), status=hyperdb.Link("status"))
+    <hyperdb.Class "issue">
+    >>> db.issue.create(title="spam", status=1)
+    1
+    >>> db.issue.create(title="eggs", status=2)
+    2
+    >>> db.issue.create(title="ham", status=4)
+    3
+    >>> db.issue.create(title="arguments", status=2)
+    4
+    >>> db.issue.create(title="abuse", status=1)
+    5
+    >>> hyperdb.Class(db, "user", username=hyperdb.Key(),
+    ... password=hyperdb.String())
+    <hyperdb.Class "user">
+    >>> db.issue.addprop(fixer=hyperdb.Link("user"))
+    >>> db.issue.getprops()
+    {"title": <hyperdb.String>, "status": <hyperdb.Link to "status">,
+     "user": <hyperdb.Link to "user">}
+    >>> db.issue.set(5, status=2)
+    >>> db.issue.get(5, "status")
+    2
+    >>> db.status.get(2, "name")
+    "in-progress"
+    >>> db.issue.get(5, "title")
+    "abuse"
+    >>> db.issue.find("status", db.status.lookup("in-progress"))
+    [2, 4, 5]
+    >>> db.issue.history(5)
+    [(<Date 2000-06-28.19:09:43>, "ping", "create", {"title": "abuse",
+    "status": 1}),
+     (<Date 2000-06-28.19:11:04>, "ping", "set", {"status": 2})]
+    >>> db.status.history(1)
+    [(<Date 2000-06-28.19:09:43>, "ping", "link", ("issue", 5, "status")),
+     (<Date 2000-06-28.19:11:04>, "ping", "unlink", ("issue", 5, "status"))]
+    >>> db.status.history(2)
+    [(<Date 2000-06-28.19:11:04>, "ping", "link", ("issue", 5, "status"))]
+
+
+For the purposes of journalling, when a Multilink property is set to a
+new list of items, the hyperdatabase compares the old list to the new
+list. The journal records "unlink" events for all the items that appear
+in the old list but not the new list, and "link" events for all the
+items that appear in the new list but not in the old list.
+
+
+Roundup Database
+----------------
+
+The Roundup database layer is implemented on top of the hyperdatabase
+and mediates calls to the database. Some of the classes in the Roundup
+database are considered issue classes. The Roundup database layer adds
+detectors and user items, and on issues it provides mail spools, nosy
+lists, and superseders.
+
+
+Reserved Classes
+~~~~~~~~~~~~~~~~
+
+Internal to this layer we reserve three special classes of items that
+are not issues.
+
+Users
+"""""
+
+Users are stored in the hyperdatabase as items of class "user".  The
+"user" class has the definition::
+
+    hyperdb.Class(db, "user", username=hyperdb.String(),
+                              password=hyperdb.String(),
+                              address=hyperdb.String())
+    db.user.setkey("username")
+
+Messages
+""""""""
+
+E-mail messages are represented by hyperdatabase items of class "msg".
+The actual text content of the messages is stored in separate files.
+(There's no advantage to be gained by stuffing them into the
+hyperdatabase, and if messages are stored in ordinary text files, they
+can be grepped from the command line.)  The text of a message is saved
+in a file named after the message item designator (e.g. "msg23") for the
+sake of the command interface (see below).  Attachments are stored
+separately and associated with "file" items. The "msg" class has the
+definition::
+
+    hyperdb.Class(db, "msg", author=hyperdb.Link("user"),
+                             recipients=hyperdb.Multilink("user"),
+                             date=hyperdb.Date(),
+                             summary=hyperdb.String(),
+                             files=hyperdb.Multilink("file"))
+
+The "author" property indicates the author of the message (a "user" item
+must exist in the hyperdatabase for any messages that are stored in the
+system). The "summary" property contains a summary of the message for
+display in a message index.
+
+
+Files
+"""""
+
+Submitted files are represented by hyperdatabase items of class "file".
+Like e-mail messages, the file content is stored in files outside the
+database, named after the file item designator (e.g. "file17"). The
+"file" class has the definition::
+
+    hyperdb.Class(db, "file", user=hyperdb.Link("user"),
+                              name=hyperdb.String(),
+                              type=hyperdb.String())
+
+The "user" property indicates the user who submitted the file, the
+"name" property holds the original name of the file, and the "type"
+property holds the MIME type of the file as received.
+
+
+Issue Classes
+~~~~~~~~~~~~~
+
+All issues have the following standard properties:
+
+=========== ==========================
+Property    Definition
+=========== ==========================
+title       hyperdb.String()
+messages    hyperdb.Multilink("msg")
+files       hyperdb.Multilink("file")
+nosy        hyperdb.Multilink("user")
+superseder  hyperdb.Multilink("issue")
+=========== ==========================
+
+Also, two Date properties named "creation" and "activity" are fabricated
+by the Roundup database layer. Two user Link properties, "creator" and
+"actor" are also fabricated. By "fabricated" we mean that no such
+properties are actually stored in the hyperdatabase, but when properties
+on issues are requested, the "creation"/"creator" and "activity"/"actor"
+properties are made available. The value of the "creation"/"creator"
+properties relate to issue creation, and the value of the "activity"/
+"actor" properties relate to the last editing of any property on the issue
+(equivalently, these are the dates on the first and last records in the
+issue's journal).
+
+
+Roundupdb Interface Specification
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The interface to a Roundup database delegates most method calls to the
+hyperdatabase, except for the following changes and additional methods::
+
+    class Database:
+        def getuid(self):
+            """Return the id of the "user" item associated with the user
+            that owns this connection to the hyperdatabase."""
+
+    class Class:
+        # Overridden methods:
+
+        def create(self, **propvalues):
+        def set(self, **propvalues):
+        def retire(self, itemid):
+            """These operations trigger detectors and can be vetoed.
+            Attempts to modify the "creation", "creator", "activity"
+            properties or "actor" cause a KeyError.
+            """
+
+    class IssueClass(Class):
+        # Overridden methods:
+
+        def __init__(self, db, classname, **properties):
+            """The newly-created class automatically includes the
+            "messages", "files", "nosy", and "superseder" properties.
+            If the 'properties' dictionary attempts to specify any of
+            these properties or a "creation", "creator", "activity" or
+            "actor" property, a ValueError is raised."""
+
+        def get(self, itemid, propname):
+        def getprops(self):
+            """In addition to the actual properties on the item, these
+            methods provide the "creation", "creator", "activity" and
+            "actor" properties."""
+
+        # New methods:
+
+        def addmessage(self, itemid, summary, text):
+            """Add a message to an issue's mail spool.
+
+            A new "msg" item is constructed using the current date, the
+            user that owns the database connection as the author, and
+            the specified summary text.  The "files" and "recipients"
+            fields are left empty.  The given text is saved as the body
+            of the message and the item is appended to the "messages"
+            field of the specified issue.
+            """
+
+        def nosymessage(self, itemid, msgid):
+            """Send a message to the members of an issue's nosy list.
+
+            The message is sent only to users on the nosy list who are
+            not already on the "recipients" list for the message.  These
+            users are then added to the message's "recipients" list.
+            """
+
+
+Default Schema
+~~~~~~~~~~~~~~
+
+The default schema included with Roundup turns it into a typical
+software bug tracker.  The database is set up like this::
+
+    pri = Class(db, "priority", name=hyperdb.String(),
+                order=hyperdb.String())
+    pri.setkey("name")
+    pri.create(name="critical", order="1")
+    pri.create(name="urgent", order="2")
+    pri.create(name="bug", order="3")
+    pri.create(name="feature", order="4")
+    pri.create(name="wish", order="5")
+
+    stat = Class(db, "status", name=hyperdb.String(),
+                 order=hyperdb.String())
+    stat.setkey("name")
+    stat.create(name="unread", order="1")
+    stat.create(name="deferred", order="2")
+    stat.create(name="chatting", order="3")
+    stat.create(name="need-eg", order="4")
+    stat.create(name="in-progress", order="5")
+    stat.create(name="testing", order="6")
+    stat.create(name="done-cbb", order="7")
+    stat.create(name="resolved", order="8")
+
+    Class(db, "keyword", name=hyperdb.String())
+
+    Class(db, "issue", fixer=hyperdb.Multilink("user"),
+                       topic=hyperdb.Multilink("keyword"),
+                       priority=hyperdb.Link("priority"),
+                       status=hyperdb.Link("status"))
+
+(The "order" property hasn't been explained yet.  It gets used by the
+Web user interface for sorting.)
+
+The above isn't as pretty-looking as the schema specification in the
+first-stage submission, but it could be made just as easy with the
+addition of a convenience function like Choice for setting up the
+"priority" and "status" classes::
+
+    def Choice(name, *options):
+        cl = Class(db, name, name=hyperdb.String(),
+                   order=hyperdb.String())
+        for i in range(len(options)):
+            cl.create(name=option[i], order=i)
+        return hyperdb.Link(name)
+
+
+Detector Interface
+------------------
+
+Detectors are Python functions that are triggered on certain kinds of
+events.  The definitions of the functions live in Python modules placed
+in a directory set aside for this purpose.  Importing the Roundup
+database module also imports all the modules in this directory, and the
+``init()`` function of each module is called when a database is opened
+to provide it a chance to register its detectors.
+
+There are two kinds of detectors:
+
+1. an auditor is triggered just before modifying an item
+2. a reactor is triggered just after an item has been modified
+
+When the Roundup database is about to perform a ``create()``, ``set()``,
+``retire()``, or ``restore`` operation, it first calls any *auditors*
+that have been registered for that operation on that class. Any auditor
+may raise a *Reject* exception to abort the operation.
+
+If none of the auditors raises an exception, the database proceeds to
+carry out the operation.  After it's done, it then calls all of the
+*reactors* that have been registered for the operation.
+
+
+Detector Interface Specification
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The ``audit()`` and ``react()`` methods register detectors on a given
+class of items::
+
+    class Class:
+        def audit(self, event, detector, priority=100):
+            """Register an auditor on this class.
+
+            'event' should be one of "create", "set", "retire", or
+            "restore". 'detector' should be a function accepting four
+            arguments. Detectors are called in priority order, execution
+            order is undefined for detectors with the same priority.
+            """
+
+        def react(self, event, detector, priority=100):
+            """Register a reactor on this class.
+
+            'event' should be one of "create", "set", "retire", or
+            "restore". 'detector' should be a function accepting four
+            arguments. Detectors are called in priority order, execution
+            order is undefined for detectors with the same priority.
+            """
+
+Auditors are called with the arguments::
+
+    audit(db, cl, itemid, newdata)
+
+where ``db`` is the database, ``cl`` is an instance of Class or
+IssueClass within the database, and ``newdata`` is a dictionary mapping
+property names to values.
+
+For a ``create()`` operation, the ``itemid`` argument is None and
+newdata contains all of the initial property values with which the item
+is about to be created.
+
+For a ``set()`` operation, newdata contains only the names and values of
+properties that are about to be changed.
+
+For a ``retire()`` or ``restore()`` operation, newdata is None.
+
+Reactors are called with the arguments::
+
+    react(db, cl, itemid, olddata)
+
+where ``db`` is the database, ``cl`` is an instance of Class or
+IssueClass within the database, and ``olddata`` is a dictionary mapping
+property names to values.
+
+For a ``create()`` operation, the ``itemid`` argument is the id of the
+newly-created item and ``olddata`` is None.
+
+For a ``set()`` operation, ``olddata`` contains the names and previous
+values of properties that were changed.
+
+For a ``retire()`` or ``restore()`` operation, ``itemid`` is the id of
+the retired or restored item and ``olddata`` is None.
+
+
+Detector Example
+~~~~~~~~~~~~~~~~
+
+Here is an example of detectors written for a hypothetical
+project-management application, where users can signal approval of a
+project by adding themselves to an "approvals" list, and a project
+proceeds when it has three approvals::
+
+    # Permit users only to add themselves to the "approvals" list.
+
+    def check_approvals(db, cl, id, newdata):
+        if newdata.has_key("approvals"):
+            if cl.get(id, "status") == db.status.lookup("approved"):
+                raise Reject, "You can't modify the approvals list " \
+                    "for a project that has already been approved."
+            old = cl.get(id, "approvals")
+            new = newdata["approvals"]
+            for uid in old:
+                if uid not in new and uid != db.getuid():
+                    raise Reject, "You can't remove other users from " \
+                        "the approvals list; you can only remove " \
+                        "yourself."
+            for uid in new:
+                if uid not in old and uid != db.getuid():
+                    raise Reject, "You can't add other users to the " \
+                        "approvals list; you can only add yourself."
+
+    # When three people have approved a project, change its status from
+    # "pending" to "approved".
+
+    def approve_project(db, cl, id, olddata):
+        if (olddata.has_key("approvals") and 
+            len(cl.get(id, "approvals")) == 3):
+            if cl.get(id, "status") == db.status.lookup("pending"):
+                cl.set(id, status=db.status.lookup("approved"))
+
+    def init(db):
+        db.project.audit("set", check_approval)
+        db.project.react("set", approve_project)
+
+Here is another example of a detector that can allow or prevent the
+creation of new items.  In this scenario, patches for a software project
+are submitted by sending in e-mail with an attached file, and we want to
+ensure that there are text/plain attachments on the message.  The
+maintainer of the package can then apply the patch by setting its status
+to "applied"::
+
+    # Only accept attempts to create new patches that come with patch
+    # files.
+
+    def check_new_patch(db, cl, id, newdata):
+        if not newdata["files"]:
+            raise Reject, "You can't submit a new patch without " \
+                          "attaching a patch file."
+        for fileid in newdata["files"]:
+            if db.file.get(fileid, "type") != "text/plain":
+                raise Reject, "Submitted patch files must be " \
+                              "text/plain."
+
+    # When the status is changed from "approved" to "applied", apply the
+    # patch.
+
+    def apply_patch(db, cl, id, olddata):
+        if (cl.get(id, "status") == db.status.lookup("applied") and 
+            olddata["status"] == db.status.lookup("approved")):
+            # ...apply the patch...
+
+    def init(db):
+        db.patch.audit("create", check_new_patch)
+        db.patch.react("set", apply_patch)
+
+
+Command Interface
+-----------------
+
+The command interface is a very simple and minimal interface, intended
+only for quick searches and checks from the shell prompt. (Anything more
+interesting can simply be written in Python using the Roundup database
+module.)
+
+
+Command Interface Specification
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+A single command, roundup, provides basic access to the hyperdatabase
+from the command line::
+
+    roundup-admin help
+    roundup-admin get [-list] designator[, designator,...] propname
+    roundup-admin set designator[, designator,...] propname=value ...
+    roundup-admin find [-list] classname propname=value ...
+
+See ``roundup-admin help commands`` for a complete list of commands.
+
+Property values are represented as strings in command arguments and in
+the printed results:
+
+- Strings are, well, strings.
+
+- Numbers are displayed the same as strings.
+
+- Booleans are displayed as 'Yes' or 'No'.
+
+- Date values are printed in the full date format in the local time
+  zone, and accepted in the full format or any of the partial formats
+  explained above.
+
+- Link values are printed as item designators.  When given as an
+  argument, item designators and key strings are both accepted.
+
+- Multilink values are printed as lists of item designators joined by
+  commas.  When given as an argument, item designators and key strings
+  are both accepted; an empty string, a single item, or a list of items
+  joined by commas is accepted.
+
+When multiple items are specified to the roundup get or roundup set
+commands, the specified properties are retrieved or set on all the
+listed items.
+
+When multiple results are returned by the roundup get or roundup find
+commands, they are printed one per line (default) or joined by commas
+(with the -list) option.
+
+
+Usage Example
+~~~~~~~~~~~~~
+
+To find all messages regarding in-progress issues that contain the word
+"spam", for example, you could execute the following command from the
+directory where the database dumps its files::
+
+    shell% for issue in `roundup find issue status=in-progress`; do
+    > grep -l spam `roundup get $issue messages`
+    > done
+    msg23
+    msg49
+    msg50
+    msg61
+    shell%
+
+Or, using the -list option, this can be written as a single command::
+
+    shell% grep -l spam `roundup get \
+        \`roundup find -list issue status=in-progress\` messages`
+    msg23
+    msg49
+    msg50
+    msg61
+    shell%
+    
+
+E-mail User Interface
+---------------------
+
+The Roundup system must be assigned an e-mail address at which to
+receive mail.  Messages should be piped to the Roundup mail-handling
+script by the mail delivery system (e.g. using an alias beginning with
+"|" for sendmail).
+
+
+Message Processing
+~~~~~~~~~~~~~~~~~~
+
+Incoming messages are examined for multiple parts. In a multipart/mixed
+message or part, each subpart is extracted and examined.  In a
+multipart/alternative message or part, we look for a text/plain subpart
+and ignore the other parts.  The text/plain subparts are assembled to
+form the textual body of the message, to be stored in the file
+associated with a "msg" class item. Any parts of other types are each
+stored in separate files and given "file" class items that are linked to
+the "msg" item.
+
+The "summary" property on message items is taken from the first
+non-quoting section in the message body. The message body is divided
+into sections by blank lines. Sections where the second and all
+subsequent lines begin with a ">" or "|" character are considered
+"quoting sections".  The first line of the first non-quoting section
+becomes the summary of the message.
+
+All of the addresses in the To: and Cc: headers of the incoming message
+are looked up among the user items, and the corresponding users are
+placed in the "recipients" property on the new "msg" item.  The address
+in the From: header similarly determines the "author" property of the
+new "msg" item. The default handling for addresses that don't have
+corresponding users is to create new users with no passwords and a
+username equal to the address.  (The web interface does not permit
+logins for users with no passwords.)  If we prefer to reject mail from
+outside sources, we can simply register an auditor on the "user" class
+that prevents the creation of user items with no passwords.
+
+The subject line of the incoming message is examined to determine
+whether the message is an attempt to create a new issue or to discuss an
+existing issue.  A designator enclosed in square brackets is sought as
+the first thing on the subject line (after skipping any "Fwd:" or "Re:"
+prefixes).
+
+If an issue designator (class name and id number) is found there, the
+newly created "msg" item is added to the "messages" property for that
+issue, and any new "file" items are added to the "files" property for
+the issue.
+
+If just an issue class name is found there, we attempt to create a new
+issue of that class with its "messages" property initialized to contain
+the new "msg" item and its "files" property initialized to contain any
+new "file" items.
+
+Both cases may trigger detectors (in the first case we are calling the
+set() method to add the message to the issue's spool; in the second case
+we are calling the create() method to create a new item).  If an auditor
+raises an exception, the original message is bounced back to the sender
+with the explanatory message given in the exception.
+
+
+Nosy Lists
+~~~~~~~~~~
+
+A standard detector is provided that watches for additions to the
+"messages" property.  When a new message is added, the detector sends it
+to all the users on the "nosy" list for the issue that are not already
+on the "recipients" list of the message.  Those users are then appended
+to the "recipients" property on the message, so multiple copies of a
+message are never sent to the same user.  The journal recorded by the
+hyperdatabase on the "recipients" property then provides a log of when
+the message was sent to whom.
+
+
+Setting Properties
+~~~~~~~~~~~~~~~~~~
+
+The e-mail interface also provides a simple way to set properties on
+issues.  At the end of the subject line, ``propname=value`` pairs can be
+specified in square brackets, using the same conventions as for the
+roundup ``set`` shell command.
+
+
+Web User Interface
+------------------
+
+The web interface is provided by a CGI script that can be run under any
+web server.  A simple web server can easily be built on the standard
+CGIHTTPServer module, and should also be included in the distribution
+for quick out-of-the-box deployment.
+
+The user interface is constructed from a number of template files
+containing mostly HTML.  Among the HTML tags in templates are
+interspersed some nonstandard tags, which we use as placeholders to be
+replaced by properties and their values.
+
+
+Views and View Specifiers
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+There are two main kinds of views: *index* views and *issue* views. An
+index view displays a list of issues of a particular class, optionally
+sorted and filtered as requested.  An issue view presents the properties
+of a particular issue for editing and displays the message spool for the
+issue.
+
+A view specifier is a string that specifies all the options needed to
+construct a particular view. It goes after the URL to the Roundup CGI
+script or the web server to form the complete URL to a view.  When the
+result of selecting a link or submitting a form takes the user to a new
+view, the Web browser should be redirected to a canonical location
+containing a complete view specifier so that the view can be bookmarked.
+
+
+Displaying Properties
+~~~~~~~~~~~~~~~~~~~~~
+
+Properties appear in the user interface in three contexts: in indices,
+in editors, and as search filters.  For each type of property, there are
+several display possibilities.  For example, in an index view, a string
+property may just be printed as a plain string, but in an editor view,
+that property should be displayed in an editable field.
+
+The display of a property is handled by functions in the
+``cgi.templating`` module.
+
+Displayer functions are triggered by ``tal:content`` or ``tal:replace``
+tag attributes in templates.  The value of the attribute provides an
+expression for calling the displayer function. For example, the
+occurrence of::
+
+    tal:content="context/status/plain"
+
+in a template triggers a call to::
+    
+    context['status'].plain()
+
+where the context would be an item of the "issue" class.  The displayer
+functions can accept extra arguments to further specify details about
+the widgets that should be generated.
+
+Some of the standard displayer functions include:
+
+========= ==============================================================
+Function  Description
+========= ==============================================================
+plain     display a String property directly;
+          display a Date property in a specified time zone with an
+          option to omit the time from the date stamp; for a Link or
+          Multilink property, display the key strings of the linked
+          items (or the ids if the linked class has no key property)
+field     display a property like the plain displayer above, but in a
+          text field to be edited
+menu      for a Link property, display a menu of the available choices
+========= ==============================================================
+
+See the `customisation`_ documentation for the complete list.
+
+
+Index Views
+~~~~~~~~~~~
+
+An index view contains two sections: a filter section and an index
+section. The filter section provides some widgets for selecting which
+issues appear in the index.  The index section is a table of issues.
+
+
+Index View Specifiers
+"""""""""""""""""""""
+
+An index view specifier looks like this (whitespace has been added for
+clarity)::
+
+    /issue?status=unread,in-progress,resolved&
+        topic=security,ui&
+        :group=priority&
+        :sort=-activity&
+        :filters=status,topic&
+        :columns=title,status,fixer
+
+
+The index view is determined by two parts of the specifier: the layout
+part and the filter part. The layout part consists of the query
+parameters that begin with colons, and it determines the way that the
+properties of selected items are displayed. The filter part consists of
+all the other query parameters, and it determines the criteria by which
+items are selected for display.
+
+The filter part is interactively manipulated with the form widgets
+displayed in the filter section.  The layout part is interactively
+manipulated by clicking on the column headings in the table.
+
+The filter part selects the union of the sets of issues with values
+matching any specified Link properties and the intersection of the sets
+of issues with values matching any specified Multilink properties.
+
+The example specifies an index of "issue" items. Only issues with a
+"status" of either "unread" or "in-progres" or "resolved" are displayed,
+and only issues with "topic" values including both "security" and "ui"
+are displayed.  The issues are grouped by priority, arranged in
+ascending order; and within groups, sorted by activity, arranged in
+descending order.  The filter section shows filters for the "status" and
+"topic" properties, and the table includes columns for the "title",
+"status", and "fixer" properties.
+
+Associated with each issue class is a default layout specifier.  The
+layout specifier in the above example is the default layout to be
+provided with the default bug-tracker schema described above in section
+4.4.
+
+Index Section
+"""""""""""""
+
+The template for an index section describes one row of the index table.
+Fragments protected by a ``tal:condition="request/show/<property>"`` are
+included or omitted depending on whether the view specifier requests a
+column for a particular property. The table cells are filled by the
+``tal:content="context/<property>"`` directive, which displays the value
+of the property.
+
+Here's a simple example of an index template::
+
+    <tr>
+      <td tal:condition="request/show/title"
+          tal:content="contex/title"></td>
+      <td tal:condition="request/show/status"
+          tal:content="contex/status"></td>
+      <td tal:condition="request/show/fixer"
+          tal:content="contex/fixer"></td>
+    </tr>
+
+Sorting
+"""""""
+
+String and Date values are sorted in the natural way. Link properties
+are sorted according to the value of the "order" property on the linked
+items if it is present; or otherwise on the key string of the linked
+items; or finally on the item ids.  Multilink properties are sorted
+according to how many links are present.
+
+Issue Views
+~~~~~~~~~~~
+
+An issue view contains an editor section and a spool section. At the top
+of an issue view, links to superseding and superseded issues are always
+displayed.
+
+Issue View Specifiers
+"""""""""""""""""""""
+
+An issue view specifier is simply the issue's designator::
+
+    /patch23
+
+
+Editor Section
+""""""""""""""
+
+The editor section is generated from a template containing
+``tal:content="context/<property>/<widget>"`` directives to insert the
+appropriate widgets for editing properties.
+
+Here's an example of a basic editor template::
+
+    <table>
+    <tr>
+        <td colspan=2
+            tal:content="python:context.title.field(size='60')"></td>
+    </tr>
+    <tr>
+        <td tal:content="context/fixer/field"></td>
+        <td tal:content="context/status/menu"></td>
+    </tr>
+    <tr>
+        <td tal:content="context/nosy/field"></td>
+        <td tal:content="context/priority/menu"></td>
+    </tr>
+    <tr>
+        <td colspan=2>
+          <textarea name=":note" rows=5 cols=60></textarea>
+        </td>
+    </tr>
+    </table>
+
+As shown in the example, the editor template can also include a ":note"
+field, which is a text area for entering a note to go along with a
+change.
+
+When a change is submitted, the system automatically generates a message
+describing the changed properties. The message displays all of the
+property values on the issue and indicates which ones have changed. An
+example of such a message might be this::
+
+    title: Polly Parrot is dead
+    priority: critical
+    status: unread -> in-progress
+    fixer: (none)
+    keywords: parrot,plumage,perch,nailed,dead
+
+If a note is given in the ":note" field, the note is appended to the
+description.  The message is then added to the issue's message spool
+(thus triggering the standard detector to react by sending out this
+message to the nosy list).
+
+
+Spool Section
+"""""""""""""
+
+The spool section lists messages in the issue's "messages" property.
+The index of messages displays the "date", "author", and "summary"
+properties on the message items, and selecting a message takes you to
+its content.
+
+Access Control
+--------------
+
+At each point that requires an action to be performed, the security
+mechanisms are asked if the current user has permission. This permission
+is defined as a Permission.
+
+Individual assignment of Permission to user is unwieldy. The concept of
+a Role, which encompasses several Permissions and may be assigned to
+many Users, is quite well developed in many projects. Roundup will take
+this path, and allow the multiple assignment of Roles to Users, and
+multiple Permissions to Roles. These definitions are not persistent -
+they're defined when the application initialises.
+
+There will be three levels of Permission. The Class level permissions
+define logical permissions associated with all items of a particular
+class (or all classes). The Item level permissions define logical
+permissions associated with specific items by way of their user-linked
+properties. The Property level permissions define logical permissions
+associated with a specific property of an item.
+
+
+Access Control Interface Specification
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The security module defines::
+
+    class Permission:
+        ''' Defines a Permission with the attributes
+            - name
+            - description
+            - klass (optional)
+            - properties (optional)
+            - check function (optional)
+
+            The klass may be unset, indicating that this permission is
+            not locked to a particular hyperdb class. There may be
+            multiple Permissions for the same name for different
+            classes.
+
+            If property names are set, permission is restricted to those
+            properties only.
+
+            If check function is set, permission is granted only when
+            the function returns value interpreted as boolean true.
+            The function is called with arguments db, userid, itemid.
+        '''
+
+    class Role:
+        ''' Defines a Role with the attributes
+            - name
+            - description
+            - permissions
+        '''
+
+    class Security:
+        def __init__(self, db):
+            ''' Initialise the permission and role stores, and add in
+                the base roles (for admin user).
+            '''
+
+        def getPermission(self, permission, classname=None, properties=None,
+                check=None):
+            ''' Find the Permission exactly matching the name, class,
+                properties list and check function.
+
+                Raise ValueError if there is no exact match.
+            '''
+
+        def hasPermission(self, permission, userid, classname=None,
+                property=None, itemid=None):
+            ''' Look through all the Roles, and hence Permissions, and
+                see if "permission" exists given the constraints of
+                classname, property and itemid.
+
+                If classname is specified (and only classname) then the
+                search will match if there is *any* Permission for that
+                classname, even if the Permission has additional
+                constraints.
+
+                If property is specified, the Permission matched must have
+                either no properties listed or the property must appear in
+                the list.
+
+                If itemid is specified, the Permission matched must have
+                either no check function defined or the check function,
+                when invoked, must return a True value.
+
+                Note that this functionality is actually implemented by the
+                Permission.test() method.
+            '''
+
+        def addPermission(self, **propspec):
+            ''' Create a new Permission with the properties defined in
+                'propspec'. See the Permission class for the possible
+                keyword args.
+            '''
+
+        def addRole(self, **propspec):
+            ''' Create a new Role with the properties defined in
+                'propspec'
+            '''
+
+        def addPermissionToRole(self, rolename, permission):
+            ''' Add the permission to the role's permission list.
+
+                'rolename' is the name of the role to add permission to.
+            '''
+
+Modules such as ``cgi/client.py`` and ``mailgw.py`` define their own
+permissions like so (this example is ``cgi/client.py``)::
+
+    def initialiseSecurity(security):
+        ''' Create some Permissions and Roles on the security object
+
+            This function is directly invoked by
+            security.Security.__init__() as a part of the Security
+            object instantiation.
+        '''
+        p = security.addPermission(name="Web Registration",
+            description="Anonymous users may register through the web")
+        security.addToRole('Anonymous', p)
+
+Detectors may also define roles in their init() function::
+
+    def init(db):
+        # register an auditor that checks that a user has the "May
+        # Resolve" Permission before allowing them to set an issue
+        # status to "resolved"
+        db.issue.audit('set', checkresolvedok)
+        p = db.security.addPermission(name="May Resolve", klass="issue")
+        security.addToRole('Manager', p)
+
+The tracker dbinit module then has in ``open()``::
+
+    # open the database - it must be modified to init the Security class
+    # from security.py as db.security
+    db = Database(config, name)
+
+    # add some extra permissions and associate them with roles
+    ei = db.security.addPermission(name="Edit", klass="issue",
+                    description="User is allowed to edit issues")
+    db.security.addPermissionToRole('User', ei)
+    ai = db.security.addPermission(name="View", klass="issue",
+                    description="User is allowed to access issues")
+    db.security.addPermissionToRole('User', ai)
+
+In the dbinit ``init()``::
+
+    # create the two default users
+    user.create(username="admin", password=Password(adminpw),
+                address=config.ADMIN_EMAIL, roles='Admin')
+    user.create(username="anonymous", roles='Anonymous')
+
+Then in the code that matters, calls to ``hasPermission`` and
+``hasItemPermission`` are made to determine if the user has permission
+to perform some action::
+
+    if db.security.hasPermission('issue', 'Edit', userid):
+        # all ok
+
+    if db.security.hasItemPermission('issue', itemid,
+                                     assignedto=userid):
+        # all ok
+
+Code in the core will make use of these methods, as should code in
+auditors in custom templates. The HTML templating may access the access
+controls through the *user* attribute of the *request* variable. It
+exposes a ``hasPermission()`` method::
+
+  tal:condition="python:request.user.hasPermission('Edit', 'issue')"
+
+or, if the *context* is *issue*, then the following is the same::
+
+  tal:condition="python:request.user.hasPermission('Edit')"
+
+
+Authentication of Users
+~~~~~~~~~~~~~~~~~~~~~~~
+
+Users must be authenticated correctly for the above controls to work.
+This is not done in the current mail gateway at all. Use of digital
+signing of messages could alleviate this problem.
+
+The exact mechanism of registering the digital signature should be
+flexible, with perhaps a level of trust. Users who supply their
+signature through their first message into the tracker should be at a
+lower level of trust to those who supply their signature to an admin for
+submission to their user details.
+
+
+Anonymous Users
+~~~~~~~~~~~~~~~
+
+The "anonymous" user must always exist, and defines the access
+permissions for anonymous users. Unknown users accessing Roundup through
+the web or email interfaces will be logged in as the "anonymous" user.
+
+
+Use Cases
+~~~~~~~~~
+
+public - end users can submit bugs, request new features, request
+    support
+    The Users would be given the default "User" Role which gives "View"
+    and "Edit" Permission to the "issue" class.
+developer - developers can fix bugs, implement new features, provide
+    support
+    A new Role "Developer" is created with the Permission "Fixer" which
+    is checked for in custom auditors that see whether the issue is
+    being resolved with a particular resolution ("fixed", "implemented",
+    "supported") and allows that resolution only if the permission is
+    available.
+manager - approvers/managers can approve new features and signoff bug
+    fixes
+    A new Role "Manager" is created with the Permission "Signoff" which
+    is checked for in custom auditors that see whether the issue status
+    is being changed similar to the developer example. admin -
+    administrators can add users and set user's roles The existing Role
+    "Admin" has the Permissions "Edit" for all classes (including
+    "user") and "Web Roles" which allow the desired actions.
+system - automated request handlers running various report/escalation
+    scripts
+    A combination of existing and new Roles, Permissions and auditors
+    could be used here.
+privacy - issues that are only visible to some users
+    A new property is added to the issue which marks the user or group
+    of users who are allowed to view and edit the issue. An auditor will
+    check for edit access, and the template user object can check for
+    view access.
+
+
+Deployment Scenarios
+--------------------
+
+The design described above should be general enough to permit the use of
+Roundup for bug tracking, managing projects, managing patches, or
+holding discussions.  By using items of multiple types, one could deploy
+a system that maintains requirement specifications, catalogs bugs, and
+manages submitted patches, where patches could be linked to the bugs and
+requirements they address.
+
+
+Acknowledgements
+----------------
+
+My thanks are due to Christy Heyl for reviewing and contributing
+suggestions to this paper and motivating me to get it done, and to Jesse
+Vincent, Mark Miller, Christopher Simons, Jeff Dunmall, Wayne Gramlich,
+and Dean Tribble for their assistance with the first-round submission.
+
+
+Changes to this document
+------------------------
+
+- Added Boolean and Number types
+- Added section Hyperdatabase Implementations
+- "Item" has been renamed to "Issue" to account for the more specific
+  nature of the Class.
+- New Templating
+- Access Controls
+- Added "actor" property
+
+------------------
+
+Back to `Table of Contents`_
+
+.. _`Table of Contents`: index.html
+.. _customisation: customizing.html
+

Added: tracker/vendor/roundup/current/doc/developers.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/developers.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,477 @@
+==================
+Developing Roundup
+==================
+
+:Version: $Revision: 1.14 $
+
+.. note::
+   The intended audience of this document is the developers of the core
+   Roundup code. If you just wish to alter some behaviour of your Roundup
+   installation, see `customising roundup`_.
+
+.. contents::
+
+Getting Started
+---------------
+
+Anyone wishing to help in the development of Roundup must read `Roundup's
+Design Document`_ and the `implementation notes`_.
+
+All development is coordinated through two resources:
+
+- roundup-dev mailing list at
+  http://lists.sourceforge.net/mailman/listinfo/roundup-devel
+- Sourceforge's issue trackers at
+  https://sourceforge.net/tracker/?group_id=31577
+
+Small Changes
+-------------
+
+Most small changes can be submitted through the Feature tracker, with patches
+attached that give context diffs of the affected source.
+
+
+CVS Access
+----------
+
+To get CVS access, contact richard at users.sourceforge.net.
+
+CVS stuff:
+
+1. to tag a release (eg. the pre-release of 0.5.0)::
+
+    cvs tag release-0-5-0-pr1
+
+1. to make a branch (eg. branching for code freeze/release)::
+
+    cvs co -d maint-0-5 -r release-0-5-0-pr1 roundup
+    cd maint-0-5 
+    cvs tag -b maint-0-5
+
+2. to check out a branch (eg. the maintenance branch for 0.5.x)::
+
+    cvs co -d maint-0-5 -r maint-0-5
+
+3. to merge changes from the maintenance branch to the trunk, in the
+   directory containing the HEAD checkout::
+
+    cvs up -j maint-0-5
+
+   though this is highly discouraged, as it generally creates a whole swag
+   of conflicts :(
+
+Standard tag names:
+
+*release-maj-min-patch[-sub]*
+  Release of the major.minor.patch release, possibly a beta or pre-release,
+  in which case *sub* will be one of "b*N*" or "pr*N*".
+*maint-maj-min*
+  Maintenance branch for the major.minor release. Patch releases are tagged in
+  this branch.
+
+Typically, release happen like this:
+
+1. work progresses in the HEAD branch until milestones are met,
+2. a series of beta releases are tagged in the HEAD until the code is
+   stable enough to freeze,
+3. the pre-release is tagged in the HEAD, with the resultant code branched
+   to the maintenance branch for that release,
+4. bugs in the release are patched in the maintenance branch, and the final
+   and patch releases are tagged there, and
+5. further major work happens in the HEAD.
+
+Project Rules
+-------------
+
+Mostly the project follows Guido's Style (though naming tends to be a little
+relaxed sometimes). In short:
+
+- 80 column width code
+- 4-space indentations
+- All modules must have a CVS Id line near the top
+
+Other project rules:
+
+- New functionality must be documented, even briefly (so at least we know
+  where there's missing documentation) and changes to tracker configuration
+  must be logged in the upgrading document.
+- subscribe to roundup-checkins to receive checkin notifications from the
+  other developers with CVS access
+- discuss any changes with the other developers on roundup-dev. If nothing
+  else, this makes sure there's no rude shocks
+- write unit tests for changes you make (where possible), and ensure that
+  all unit tests run before committing changes
+- run pychecker over changed code
+
+The administrators of the project reserve the right to boot developers who
+consistently check in code which is either broken or takes the codebase in
+directions that have not been agreed to.
+
+
+Debugging Aids
+--------------
+
+Try turning on logging of DEBUG level messages. This may be done a number
+of ways, depending on what it is you're testing:
+
+1. If you're testing the database unit tests, then set the environment
+   variable ``LOGGING_LEVEL=DEBUG``. This may be done like so:
+
+    LOGGING_LEVEL=DEBUG python run_tests.py
+
+   This variable replaces the older HYPERDBDEBUG environment var.
+
+2. If you're testing a particular tracker, then set the logging level in
+   your tracker's ``config.ini``.
+
+
+Internationalization Notes
+--------------------------
+
+How stuff works:
+
+1. Strings that may require translation (messages in human language)
+   are marked in the source code.  This step is discussed in
+   `Marking Strings for Translation`_ section.
+
+2. These strings are all extracted into Message Template File
+   ``locale/roundup.pot`` (_`POT` file).  See `Extracting Translatable
+   Messages`_ below.
+
+3. Language teams use POT file to make Message Files for national
+   languages (_`PO` files).  All PO files for Roundup are kept in
+   the ``locale`` directory.  Names of these files are target
+   locale names, usually just 2-letter language codes.  `Translating
+   Messages`_ section of this chapter gives useful hints for
+   message translators.
+
+4. Translated Message Files are compiled into binary form (_`MO` files)
+   and stored in ``locale`` directory (but not kept in the `Roundup
+   CVS`_ repository, as they may be easily made from PO files).
+   See `Compiling Message Catalogs`_ section.
+
+5. Roundup installer creates runtime locale structure on the file
+   system, putting MO files in their appropriate places.
+
+6. Runtime internationalization (_`I18N`) services use these MO files
+   to translate program messages into language selected by current
+   Roundup user.  Roundup command line interface uses locale name
+   set in OS environment variable ``LANGUAGE``, ``LC_ALL``,
+   ``LC_MESSAGES``, or ``LANG`` (in that order).  Roundup Web User
+   Interface uses language selected by currently authenticated user.
+
+Additional details may be found in `GNU gettext`_ and Python `gettext
+module`_ documentation.
+
+`Roundup source distribution`_ includes POT and PO files for message
+translators, and also pre-built MO files to facilitate installations
+from source.  Roundup binary distribution includes MO files only.
+
+.. _GNU gettext:
+
+GNU gettext package
+^^^^^^^^^^^^^^^^^^^
+
+This chapter is full of references to GNU `gettext package`_.
+GNU gettext is a "must have" for nearly all steps of internationalizing
+any program, and it's manual is definetely a recommended reading
+for people involved in `I18N`_.
+
+There are GNU gettext ports to all major OS platforms.
+Windows binaries are available from `GNU mirror sites`_.
+
+Roundup does not use GNU gettext at runtime, but it's tools
+are used for `extracting translatable messages`_, `compiling
+message catalogs`_ and, optionally, for `translating messages`_.
+
+Note that ``gettext`` package in some OS distributions means just
+runtime tools and libraries.  In such cases gettext development tools
+are usually distributed in separate package named ``gettext-devel``.
+
+Marking Strings for Translation
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Strings that need translation must be marked in the source code.
+Following subsections explain how this is done in different cases.
+
+If translatable string is used as a format string, it is recommended
+to always use *named* format specifiers::
+
+  _('Index of %(classname)s') % locals()
+
+This helps translators to better understand the context of the
+message and, with Python formatting, remove format specifier altogether
+(which is sometimes useful, especially in singular cases of `Plural Forms`_).
+
+When there is more than one format specifier in the translatable
+format string, named format specifiers **must** be used almost always,
+because translation may require different order of items.
+
+It is better to *not* mark for translation strings that are not
+locale-dependent, as this makes it more difficult to keep track
+of translation completeness.  For example, string ``</ol></body></html>``
+(in ``index()`` method of the request handler in ``roundup_server``
+script) has no human readable parts at all, and needs no translations.
+Such strings are left untranslated in PO files, and are reported
+as such by PO status checkers (e.g. ``msgfmt --statistics``).
+
+Command Line Interfaces
+~~~~~~~~~~~~~~~~~~~~~~~
+
+Scripts and routines run from the command line use "static" language
+defined by environment variables recognized by ``gettext`` module
+from Python library (``LANGUAGE``, ``LC_ALL``, ``LC_MESSAGES``, and
+``LANG``).  Primarilly, these are ``roundup-admin`` script and
+``admin.py`` module, but also help texts and startup error messages
+in other scripts and their supporting modules.
+
+For these interfaces, Python ``gettext`` engine must be initialized
+to use Roundup message catalogs.  This is normally done by including
+the following line in the module imports::
+
+  from i18n import _, ngettext
+
+Simple translations are automatically marked by calls to builtin
+message translation function ``_()``::
+
+  print _("This message is translated")
+
+Translations for messages whose grammatical depends on a number
+must be done by ``ngettext()`` function::
+
+  print ngettext("Nuked %i file", "Nuked %i files", number_of_files_nuked)
+
+Deferred Translations
+~~~~~~~~~~~~~~~~~~~~~
+
+Sometimes translatable strings appear in the source code in untranslated
+form [#note_admin.py]_ and must be translated elsewhere.
+Example::
+
+  for meal in ("spam", "egg", "beacon"):
+      print _(meal)
+
+In such cases, strings must be marked for translation without actual
+call to the translating function.  To mark these strings, we use Python
+feature of automatic concatenation of adjacent strings and different
+types of string quotes::
+
+  strings_to_translate = (
+      ''"This string will be translated",
+      ""'me too',
+      ''r"\raw string",
+      ''"""
+      multiline string"""
+  )
+
+.. [#note_admin.py] In current Roundup sources, this feature is
+   extensively used in the ``admin`` module using method docstrings
+   as help messages.
+
+Web User Interface
+~~~~~~~~~~~~~~~~~~
+
+For Web User Interface, translation services are provided by Client
+object.  Action classes have methods ``_()`` and ``gettext()``,
+delegating translation to the Client instance.  In HTML templates,
+translator object is available as context variable ``i18n``.
+
+HTML templates have special markup for translatable strings.
+The syntax for this markup is defined on `ZPTInternationalizationSupport`_
+page.  Roundup translation service currently ignores values for
+``i18n:domain``, ``i18n:source`` and ``i18n:target``.
+
+Template markup examples:
+
+* simplest case::
+
+    <div i18n:translate="">
+     Say
+     no
+     more!
+    </div>
+
+  this will result in msgid ``"Say no more!"``, with all leading and
+  trailing whitespace stripped, and inner blanks replaced with single
+  space character.
+
+* using variable slots::
+
+    <div i18n:translate="">
+     And now...<br/>
+     No.<span tal:replace="number" i18n:name="slideNo" /><br/>
+     THE LARCH
+    </div>
+
+  Msgid will be: ``"And now...<br /> No.${slideNo}<br /> THE LARCH"``.
+  Template rendering will use context variable ``number`` (you may use
+  any expression) to put instead of ``${slideNo}`` in translation.
+
+* attribute translation::
+
+    <button name="btn_wink" value=" Wink " i18n:attributes="value" />
+
+  will translate the caption (and return value) for the "wink" button.
+
+* explicit msgids.  Sometimes it may be useful to specify msgid
+  for the element translation explicitely, like this::
+
+    <span i18n:translate="know what i mean?">this text is ignored</span>
+
+  When rendered, element contents will be replaced by translation
+  of the string specified in ``i18n:translate`` attribute.
+
+* ``i18n`` in `TALES`_.  You may translate strings in `TALES`_ python
+  expressions::
+
+    <span tal:replace="python: i18n.gettext('Oh, wicked.')" />
+
+* plural forms.  There is no markup for plural forms in `TAL`_ i18n.
+  You must use python expression for that::
+
+    <span tal:replace="python: i18n.ngettext(
+      'Oh but it\'s only %i shilling.',
+      'Oh but it\'s only %i shillings.',
+      fine) % fine"
+    />
+
+Extracting Translatable Messages
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+The most common tool for message extraction is ``xgettext`` utility
+from `GNU gettext package`_.  Unfortunately, this utility has no means
+of `Deferred Translations`_ in Python sources.  There is ``xpot`` tool
+from Francois Pinard free `PO utilities`_ that allows to mark strings
+for deferred translations, but it does not handle `plural forms`_.
+
+Roundup overcomes these limitations by using both of these utilities.
+This means that you need both `GNU gettext`_ tools and `PO utilities`_
+to build the Message Template File yourself.
+
+Latest Message Template File is kept in `Roundup CVS`_ and distributed
+with `Roundup Source`_.  If you wish to rebuild the template yourself,
+make sure that you have both ``xpot`` and ``xgettext`` installed and
+just run ``gmake`` (or ``make``, if you are on a `GNU`_ system like
+`linux`_ or `cygwin`_) in the ``locale`` directory.
+
+For on-site i18n, Roundup provides command-line utility::
+
+  roundup-gettext <tracker_home>
+
+extracting translatable messages from tracker's html templates.
+This utility creates message template file ``messages.pot`` in
+``locale`` subdirectory of the tracker home directory.  Translated
+messages may be put in *locale*.po files (where *locale* is selected
+locale name) in the same directory, e.g.: ``locale/ru.po``.
+These message catalogs are searched prior to system-wide translations
+kept in the ``share`` directory.
+
+Translating Messages
+^^^^^^^^^^^^^^^^^^^^
+
+Gettext Message File (`PO`_ file) is a plain text file, that can be created
+by simple copying ``roundup.pot`` to new .po file, like this::
+
+  $ cp roundup.pot ru.po
+
+The name of PO file is target locale name, usually just 2-letter language
+code (``ru`` for Russian in the above example).  Alternatively, PO file
+may be initialized by ``msginit`` utility from `GNU gettext`_ tools::
+
+  $ msginit -i roundup.pot
+
+``msginit`` will check your current locale, and initialize the header
+entry, setting language name, rules for `plural forms`_ and, if available,
+translator's name and email address.  The name for PO file is also chosen
+based on current locale.
+
+Next, you will need to edit this file, filling all ``msgstr`` lines with
+translations of the above ``msgid`` entries.  PO file is a plain text
+file that can be edited with any text editor.  However, there are several
+tools that may help you with this process:
+
+ - ``po-mode`` for `emacs`_.  One of `GNU gettext`_ tools.  Very handy,
+   definitely recommended if you are comfortable with emacs.  Cannot
+   handle `plural forms`_ per se, but allows to edit them in simple
+   text mode.
+
+ - `po filetype plugin`_ for `vim`_.  Does not do as much as ``po-mode``,
+   but helps in finding untranslated and fuzzy strings, and checking
+   code references.  Please contact `alexander smishlajev`_ if you
+   prefer this, as i have patched this plugin a bit.  I have also
+   informed the original plugin author about these changes, but got
+   no reply so far.
+
+ - `poEdit`_ by Vaclav Slavik.  Nice cross-platform GUI editor.
+   Unfortunately, it does not handle `plural forms`_.  Even worse,
+   it deletes all messages with plural forms when the file is saved.
+   Still, it may be useful to initially translate most of the messages
+   and add plural form messages later.
+
+ - `KBabel`_.  Being part of `KDE`_, it works in X windows only.
+    At the first glance looks pretty hairy, with all bells and whistles.
+    Haven't had much experience with it, though.
+
+Compiling Message Catalogs
+^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Message catalogs (`PO`_ files) must be compiled into binary form
+(`MO`_ files) before they can be used in the application.  This
+compilation is handled by ``msgfmt`` utility from `GNU gettext`_
+tools.  ``GNUmakefile`` in the ``locale`` directory automatically
+compiles all existing message catalogs after updating them from
+Roundup source files.  If you wish to rebuild an individual `MO`_
+file without making everything else, you may, for example::
+
+  $ msgfmt --statistics -o ru.mo ru.po
+
+This way, message translators can check their `PO`_ files without
+extracting strings from source.  (Note: String extraction requires
+additional utility that is not part of `GNU gettext`_.  See `Extracting
+Translatable Messages`_.)
+
+At run time, Roundup automatically compiles message catalogs whenever
+`PO`_ file is changed.
+
+-----------------
+
+Back to `Table of Contents`_
+
+.. _`Table of Contents`: index.html
+.. _`Customising Roundup`: customizing.html
+.. _`Roundup's Design Document`: spec.html
+.. _`implementation notes`: implementation.html
+
+
+.. _External hyperlink targets:
+
+.. _alexander smishlajev:
+.. _als: http://sourceforge.net/users/a1s/
+.. _cygwin: http://www.cygwin.com/
+.. _emacs: http://www.gnu.org/software/emacs/
+.. _gettext package: http://www.gnu.org/software/gettext/
+.. _gettext module: http://docs.python.org/lib/module-gettext.html
+.. _GNU: http://www.gnu.org/
+.. _GNU mirror sites: http://www.gnu.org/prep/ftp.html
+.. _KBabel: http://i18n.kde.org/tools/kbabel/
+.. _KDE: http://www.kde.org/
+.. _linux: http://www.linux.org/
+.. _Plural Forms:
+    http://www.gnu.org/software/gettext/manual/html_node/gettext_150.html
+.. _po filetype plugin:
+    http://vim.sourceforge.net/scripts/script.php?script_id=695
+.. _PO utilities: http://po-utils.progiciels-bpi.ca/
+.. _poEdit: http://poedit.sourceforge.net/
+.. _Roundup CVS: http://sourceforge.net/cvs/?group_id=31577
+.. _Roundup Source:
+.. _Roundup source distribution:
+.. _Roundup binary distribution:
+    http://sourceforge.net/project/showfiles.php?group_id=31577
+.. _TAL:
+.. _Template Attribute Language:
+   http://dev.zope.org/Wikis/DevSite/Projects/ZPT/TAL%20Specification%201.4
+.. _TALES:
+.. _Template Attribute Language Expression Syntax:
+   http://dev.zope.org/Wikis/DevSite/Projects/ZPT/TALES%20Specification%201.3
+.. _vim: http://www.vim.org/
+.. _ZPTInternationalizationSupport: http://dev.zope.org/Wikis/DevSite/Projects/ComponentArchitecture/ZPTInternationalizationSupport

Added: tracker/vendor/roundup/current/doc/features.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/features.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,109 @@
+================
+Roundup Features
+================
+
+Roundup is a simple-to-use and -install issue-tracking system with
+web, e-mail and command-line interfaces. It is based on the winning design
+from Ka-Ping Yee in the Software Carpentry "Track" design competition.
+
+*simple to install*
+ - installation (including web interface) takes about 30 minutes
+ - instant-gratification ``python demo.py`` :)
+ - two templates included in the distribution for you to base your tracker on
+ - play with the demo, customise it and then use *it* as the template for
+   your production tracker
+ - requires *no* additional support software - python (2.3+) is
+   enough to get you going
+ - easy to set up higher-performance storage backends like sqlite_,
+   metakit_, mysql_ and postgresql_
+
+*simple to use*
+ - accessible through the web, email, command-line or Python programs
+ - may be used to track bugs, features, user feedback, sales opportunities,
+   milestones, ...
+ - automatically keeps a full history of changes to issues with
+   configurable verbosity and easy access to information about who created
+   or last modified *any* item in the database
+ - issues have their own mini mailing list (nosy list)
+ - users may sign themselves up, there may be automatic signup for
+   incoming email and users may handle their own password reset requests
+
+*highly configurable*
+ - web interface HTML is fully editable
+ - database schema is also fully editable (only the "user" class is required)
+   with a full set of data types (including dates and many-to-many relations)
+   across all storages available
+ - customised automatic auditors and reactors may be written that perform
+   actions before and after changes are made to entries in the database,
+   or may veto the creation or modification of items int he database
+ - samples are provided for all manner of configuration changes and
+   customisations
+
+*fast, scalable*
+ - with the sqlite, metakit, mysql and postgresql backends, roundup is
+   also fast and scalable, easily handling thousands of issues and users
+   with decent response times
+ - database indexes are automatically added for those backends that
+   support them (sqlite, metakit, mysql and postgresql)
+ - indexed text searching giving fast responses to searches across all
+   messages and indexed string properties
+ - support for the Xapian full-text indexing engine for large trackers
+
+*documented*
+ - documentation exists for installation, upgrading, maintenance, users and
+   customisation
+
+*web interface*
+ - fully editable interfaces for listing and display of items
+ - extendable to include wizards, parent/meta bug displays, ...
+ - differentiates between anonymous, known and admin users
+ - may be set up to require login, and may also only allow admin users
+   to register new users
+ - authentication of user registration and user-driven password resetting
+   using email and one time keys
+ - may be run through CGI as a normal cgi script, as a stand-alone
+   web server, or through Zope
+ - searching may be performed using many constraints, including a full-text
+   search of messages attached to issues
+ - file attachments (added through the web or email) are served up with the
+   correct content-type and filename
+ - email change messages generated by roundup appear to be sent by the
+   person who made the change, but responses will go back through the nosy
+   list by default
+ - flexible access control built around Permissions and Roles with assigned
+   Permissions
+ - generates valid HTML4 or XHTML
+ - detects concurrent user changes
+ - saving and editing of user-defined queries which may optionally be
+   shared with other users
+
+*e-mail interface*
+ - may be set up using sendmail-like delivery alias, POP polling or mailbox
+   polling
+ - may auto-register users who send in mail and aren't known to roundup
+ - nosy list configuration controls how people are added and when messages
+   are sent to the list
+ - auto-filing of "unformatted" messages into a particular class
+ - e-mail attachments are handled sanely, being attached to the issue they're
+   intended for, and forwarded on to the nosy list
+ - sane handling of content-type and content-encoding of messages (text/plain
+   preferred in all situations)
+ - email packages that display threading will show issue messages correctly
+   threaded
+ - users may send in messages from multiple addresses and be associated
+   with the same roundup username
+ - built-in security features like TLS and APOP
+
+*command-line*
+ - may be used to interactively manage roundup databases
+ - may be scripted using standard shell scripting
+ - roundup's API may also be used by other Python programs - a sample is
+   provided that runs once a day and emails people their assigned issues
+ - a variety of sample shell scripts are provided (weekly reports, issue
+   generation, ...)
+
+.. _sqlite: http://www.hwaci.com/sw/sqlite/
+.. _metakit: http://www.equi4.com/metakit/
+.. _mysql: http://sourceforge.net/projects/mysql-python
+.. _postgresql: http://initd.org/software/initd/psycopg
+

Added: tracker/vendor/roundup/current/doc/glossary.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/glossary.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,37 @@
+================
+Roundup Glossary
+================
+
+:Version: $Revision: 1.5 $
+
+.. contents::
+
+
+class
+   a definition of the properties and behaviour of a set of items
+db (or hyperdb)
+   a collection of items
+designator
+   a combined class + itemid reference to any item in the hyperdb
+itemid
+   a numeric reference to a particular item of one class
+item
+   a collection of data that forms one entry in the hyperdb.
+property
+   one element of data that makes up an item. In Roundup, item
+   properties may be changed as needed - even after the tracker
+   has been initialised and used in production.
+schema
+   the definition of all the classes that make up an tracker
+tracker
+   the schema and hyperdb that forms one issue tracker
+tracker home
+   the physical location on disk of a tracker
+
+
+-----------------
+
+Back to `Table of Contents`_
+
+.. _`Table of Contents`: index.html
+

Added: tracker/vendor/roundup/current/doc/images/edit.png
==============================================================================
Binary file. No diff available.

Added: tracker/vendor/roundup/current/doc/images/hyperdb.png
==============================================================================
Binary file. No diff available.

Added: tracker/vendor/roundup/current/doc/images/logo-acl-medium.png
==============================================================================
Binary file. No diff available.

Added: tracker/vendor/roundup/current/doc/images/logo-codesourcery-medium.png
==============================================================================
Binary file. No diff available.

Added: tracker/vendor/roundup/current/doc/images/logo-software-carpentry-standard.png
==============================================================================
Binary file. No diff available.

Added: tracker/vendor/roundup/current/doc/images/roundup-1.png
==============================================================================
Binary file. No diff available.

Added: tracker/vendor/roundup/current/doc/images/roundup.png
==============================================================================
Binary file. No diff available.

Added: tracker/vendor/roundup/current/doc/implementation.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/implementation.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,55 @@
+====================
+Implementation notes
+====================
+
+:Version: $Revision: 1.6 $
+
+[see also the roundup package docstring]
+
+There have been some modifications to the spec. I've marked these in the
+source with 'XXX' comments when I remember to.
+
+In short:
+ Class.find() - may match multiple properties, uses keyword args.
+
+ Class.filter() - isn't in the spec and it's very useful to have at the
+    Class level.
+
+ CGI interface index view specifier layout part - lose the '+' from the
+    sorting arguments (it's a reserved URL character ;). Just made no
+    prefix mean ascending and '-' prefix descending.
+
+ ItemClass - renamed to IssueClass to better match it only having one
+    hypderdb class "issue". Allowing > 1 hyperdb class breaks the
+    "superseder" multilink (since it can only link to one thing, and
+    we'd want bugs to link to support and vice-versa).
+
+ template - the call="link()" is handled by special-case mechanisms in
+    my top-level CGI handler. In a nutshell, the handler looks for a
+    method on itself called 'index%s' or 'item%s' where %s is a class.
+    Most items pass on to the templating mechanism, but the file class
+    _always_ does downloading. It'll probably stay this way too...
+
+ template - call="link(property)" may be used to link "the current item"
+    (from an index) - the link text is the property specified.
+
+ template - added functions that I found very useful: List, History and
+    Submit.
+
+ template - items must specify the message lists, history, etc. Having
+    them by default was sometimes not wanted.
+
+ template - index view determines its default columns from the
+    template's ``tal:condition="request/show/<property>"`` directives.
+
+ template - menu() and field() look awfully similar now .... ;)
+
+ roundup_admin.py - the command-line tool has a lot more commands at its
+    disposal
+
+-----------------
+
+Back to `Table of Contents`_
+
+.. _`Table of Contents`: index.html
+

Added: tracker/vendor/roundup/current/doc/index.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/index.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,170 @@
+=======================================================
+Roundup: an Issue-Tracking System for Knowledge Workers
+=======================================================
+
+Contents
+========
+
+- Features__
+- Installation__ and Upgrading__ existing installs
+- `Frequently Asked Questions`__
+- `User Guide`__
+- `Configuring and Customising Roundup`__
+- `Administering Roundup Trackers`__
+- `Roundup's Design`__ (original__)
+- `Developing Roundup`__
+- `Roundup Tracker Templates`__
+- Contact_
+- Acknowledgements_
+- License_
+
+__ features.html
+__ installation.html
+__ upgrading.html
+__ FAQ.html
+__ user_guide.html
+__ customizing.html
+__ admin_guide.html
+__ design.html
+__ spec.html
+__ developers.html
+__ tracker_templates.html
+
+Contact
+=======
+
+For general support enquiries about usage, a mailing list is available:
+
+    roundup-users at sourceforge.net
+
+If you've got a great idea for roundup, or have found a bug, please
+submit an issue to the tracker at: 
+
+    http://sourceforge.net/tracker/?group_id=31577
+
+For discussions about developing or enhancing roundup:
+
+    roundup-devel at sourceforge.net
+
+The admin for this project is Richard Jones:
+
+    richard at users.sourceforge.net
+
+but he should only be contacted directly when none of the
+above avenues of contact are suitable.
+
+
+Acknowledgements
+================
+
+Go Ping, you rock! Also, go Common Ground, ekit.com and Bizar Software for
+letting me implement this system on their time.
+
+Thanks also to the many people on the mailing list, in the sourceforge
+project and those who just report bugs:
+Thomas Arendsen Hein,
+Anthony Baxter,
+Marlon van den Berg,
+Bo Berglund,
+Stéphane Bidoul,
+Cameron Blackwood,
+Jeff Blaine,
+Duncan Booth,
+Seb Brezel,
+J Alan Brogan,
+Titus Brown,
+Steve Byan,
+Godefroid Chapelle,
+Roch'e Compaan,
+Wil Cooley,
+Joe Cooper,
+Kelley Dagley,
+Paul F. Dubois,
+Andrew Eland,
+Jeff Epler,
+Tom Epperly,
+Tamer Fahmy,
+Vickenty Fesunov,
+Hernan Martinez Foffani,
+Stuart D. Gathman,
+Ajit George,
+Frank Gibbons,
+Johannes Gijsbers,
+Gus Gollings,
+Dan Grassi,
+Robin Green,
+Jason Grout,
+Charles Groves,
+Engelbert Gruber,
+Bruce Guenter,
+Thomas Arendsen Hein,
+Juergen Hermann,
+Uwe Hoffmann,
+Tobias Hunger,
+Simon Hyde,
+Paul Jimenez,
+Christophe Kalt,
+Brian Kelley,
+James Kew,
+Sheila King,
+Michael Klatt,
+Bastian Kleineidam,
+Axel Kollmorgen,
+Detlef Lannert,
+Andrey Lebedev,
+Henrik Levkowetz,
+David Linke,
+Fredrik Lundh,
+Georges Martin,
+Gordon McMillan,
+John F Meinel Jr,
+Stefan Niederhauser,
+Truls E. Næss,
+Patrick Ohly,
+Luke Opperman,
+Eddie Parker,
+Will Partain,
+Ewout Prangsma,
+Marcus Priesch,
+Bernhard Reiter,
+Roy Rapoport,
+John P. Rouillard,
+Ollie Rutherfurd,
+Toby Sargeant,
+Giuseppe Scelsi,
+Ralf Schlatterbeck,
+Gregor Schmid,
+Florian Schulze,
+Klamer Schutte,
+Dougal Scott,
+Stefan Seefeld,
+Jouni K Seppänen,
+Jeffrey P Shell,
+Dan Shidlovsky,
+Joel Shprentz,
+Terrel Shumway,
+Emil Sit,
+Alexander Smishlajev,
+Nathaniel Smith,
+Maciej Starzyk,
+Mitchell Surface,
+Mike Thompson,
+Michael Twomey,
+Karl Ulbrich,
+Martin Uzak,
+Darryl VanDorp,
+J Vickroy,
+Timothy J. Warren,
+William (Wilk),
+Tue Wennerberg,
+Matt Wilbert,
+Chris Withers,
+Milan Zamazal.
+
+
+
+License
+=======
+
+See COPYING.txt in the software distribution for the licensing terms.
+

Added: tracker/vendor/roundup/current/doc/installation.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/installation.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,891 @@
+==================
+Installing Roundup
+==================
+
+:Version: 1.76
+
+.. contents::
+   :depth: 2
+
+
+Overview
+========
+
+Broken out separately, there are several conceptual pieces to a
+Roundup installation:
+
+Roundup trackers
+ Trackers consist of issues (be they bug reports or otherwise), tracker
+ configuration file(s), web HTML files etc. Roundup trackers are initialised
+ with a "Template" which defines the fields usable/assignable on a
+ per-issue basis.  Descriptions of the provided templates are given in
+ `choosing your template`_.
+
+Roundup support code
+ Installed into your Python install's lib directory.
+
+Roundup scripts
+ These include the email gateway, the roundup
+ HTTP server, the roundup administration command-line interface, etc.
+
+
+Prerequisites
+=============
+
+Roundup requires Python 2.3 or newer with a functioning anydbm
+module. Download the latest version from http://www.python.org/.
+It is highly recommended that users install the latest patch version
+of python as these contain many fixes to serious bugs.
+
+Some variants of Linux will need an additional "python dev" package
+installed for Roundup installation to work. Debian and derivatives, are
+known to require this.
+
+If you're on windows, you will either need to be using the ActiveState python
+distribution (at http://www.activestate.com/Products/ActivePython/), or you'll
+have to install the win32all package separately (get it from
+http://starship.python.net/crew/mhammond/win32/).
+
+
+Optional Components
+===================
+
+You may optionally install and use:
+
+An RDBMS
+  Sqlite, MySQL and Postgresql are all supported by Roundup and will be
+  used if available. One of these is recommended if you are anticipating a
+  large user base (see `choosing your backend`_ below).
+
+Xapian full-text indexer
+  The Xapian_ full-text indexer is also supported and will be used by
+  default if it is available. This is strongly recommended if you are
+  anticipating a large number of issues (> 5000).
+
+  You may install Xapian at any time, even after a tracker has been
+  installed and used. You will need to run the "roundup-admin reindex"
+  command if the tracker has existing data.
+
+  Roundup requires Xapian *newer* than 0.9.2 - it may be necessary for
+  you to install a snapshot. Snapshot "0.9.2_svn6532" has been tried
+  successfully.
+
+.. _Xapian: http://www.xapian.org/
+
+
+Getting Roundup
+===============
+
+.. note::
+    Some systems, such as Debian and NetBSD, already have Roundup
+    installed. Try running the command "roundup-admin" with no arguments,
+    and if it runs you may skip the `Basic Installation Steps`_
+    below and go straight to `configuring your first tracker`_.
+
+Download the latest version from http://roundup.sf.net/.
+
+If you're using WinZIP's "classic" interface, make sure the "Use
+folder names" check box is checked before you extract the files.
+
+
+For The Really Impatient
+========================
+
+If you just want to give Roundup a whirl Right Now, then simply run
+``python demo.py``. If you used the Windows installer, you should run the
+``roundup-demo`` program instead. Users of other binary distributions or
+pre-installed Roundup will need to download the source to use it.
+
+This will set up a simple demo tracker on your machine. [1]_
+When it's done, it'll print out a URL to point your web browser at
+so you may start playing. Three users will be set up:
+
+1. anonymous - the "default" user with permission to do very little
+2. demo (password "demo") - a normal user who may create issues
+3. admin (password "admin") - an administrative user who has complete
+   access to the tracker
+
+.. [1] Demo tracker is set up to be accessed by localhost browser.
+       If you run demo on a server host, please stop the demo when
+       it has shown startup notice, open file ``demo/config.ini`` with
+       your editor, change host name in the ``web`` option in section
+       ``[tracker]``, save the file, then re-run the demo program.
+
+Installation
+============
+
+Set aside 15-30 minutes. There's several steps to follow in your
+installation:
+
+1. `basic installation steps`_ if Roundup is not installed on your system
+2. `configuring your first tracker`_ that all installers must follow
+3. then optionally `configure a web interface`_
+4. and optionally `configure an email interface`_
+5. `UNIX environment steps`_ to take if you're installing on a shared
+   UNIX machine and want to restrict local access to roundup
+6. `additional language codecs`_
+
+For information about how Roundup installs, see the `administration
+guide`_.
+
+
+Basic Installation Steps
+------------------------
+
+To install the Roundup support code into your Python tree and
+Roundup scripts into /usr/bin (substitute that path for whatever is
+appropriate on your system). You need to have write permissions
+for these locations, eg. being root on unix::
+
+    python setup.py install
+
+If you would like to place the Roundup scripts in a directory other
+than ``/usr/bin``, then specify the preferred location with
+``--install-script``. For example, to install them in
+``/opt/roundup/bin``::
+
+    python setup.py install --install-scripts=/opt/roundup/bin
+
+You can also use the ``--prefix`` option to use a completely different
+base directory, if you do not want to use administrator rights. If you
+choose to do this, you may have to change Python's search path (sys.path)
+yourself.
+
+
+Configuring your first tracker
+------------------------------
+
+1. To create a Roundup tracker (necessary to do before you can
+   use the software in any real fashion), you need to set up a "tracker
+   home":
+
+   a. (Optional) If you intend to keep your roundup trackers
+      under one top level directory which does not exist yet,
+      you should create that directory now.  Example::
+
+         mkdir /opt/roundup/trackers
+
+   b. Either add the Roundup script location to your ``PATH``
+      environment variable or specify the full path to
+      the command in the next step.
+
+   c. Install a new tracker with the command ``roundup-admin install``.
+      You will be asked a series of questions.  Descriptions of the provided
+      templates can be found in `choosing your template`_ below.  Descriptions
+      of the available backends can be found in `choosing your backend`_
+      below.  The questions will be something like (you may have more
+      templates or backends available)::
+
+          Enter tracker home: /opt/roundup/trackers/support
+          Templates: classic
+          Select template [classic]: classic
+          Back ends: anydbm, mysql, sqlite
+          Select backend [anydbm]: anydbm
+
+      Note: "Back ends" selection list depends on availability of
+      third-party database modules.  Standard python distribution
+      includes anydbm module only.
+
+      The "support" part of the tracker name can be anything you want - it
+      is going to be used as the directory that the tracker information
+      will be stored in.
+
+      You will now be directed to edit the tracker configuration and
+      initial schema.  At a minimum, you must set "main :: admin_email"
+      (that's the "admin_email" option in the "main" section) "mail ::
+      host", "tracker :: web" and "mail :: domain".  If you get stuck,
+      and get configuration file errors, then see the `tracker
+      configuration`_ section of the `customisation documentation`_.
+
+      If you just want to get set up to test things quickly (and follow
+      the instructions in step 3 below), you can even just set the
+      "tracker :: web" variable to::
+
+         web = http://localhost:8080/support/
+
+      The URL *must* end in a '/', or your web interface *will not work*.
+      See `Customising Roundup`_ for details on configuration and schema
+      changes. You may change any of the configuration after
+      you've initialised the tracker - it's just better to have valid values
+      for this stuff now.
+
+   d. Initialise the tracker database with ``roundup-admin initialise``.
+      You will need to supply an admin password at this step. You will be
+      prompted::
+
+          Admin Password:
+                 Confirm:
+
+      Note: running this command will *destroy any existing data in the
+      database*. In the case of MySQL and PostgreSQL, any exsting database
+      will be dropped and re-created.
+
+      Once this is done, the tracker has been created.
+
+2. At this point, your tracker is set up, but doesn't have a nice user
+   interface. To set that up, we need to `configure a web interface`_ and
+   optionally `configure an email interface`_. If you want to try your
+   new tracker out, assuming "tracker :: web" is set to
+   ``'http://localhost:8080/support/'``, run::
+
+     roundup-server support=/opt/roundup/trackers/support
+
+   then direct your web browser at:
+
+     http://localhost:8080/support/
+
+   and you should see the tracker interface.
+
+
+Choosing Your Template
+----------------------
+
+Classic Template
+~~~~~~~~~~~~~~~~
+
+The classic template is the one defined in the `Roundup Specification`_. It
+holds issues which have priorities and statuses. Each issue may also have a
+set of messages which are disseminated to the issue's list of nosy users.
+
+Minimal Template
+~~~~~~~~~~~~~~~~
+
+The minimal template has the minimum setup required for a tracker
+installation. That is, it has the configuration files, defines a user database
+and the basic HTML interface to that. It's a completely clean slate for you to
+create your tracker on.
+
+
+Choosing Your Backend
+---------------------
+
+The actual storage of Roundup tracker information is handled by backends.
+There's several to choose from, each with benefits and limitations:
+
+========== =========== ===== ==============================
+Name       Speed       Users   Support
+========== =========== ===== ==============================
+anydbm     Slowest     Few   Always available
+sqlite     Fastest(*)  Few   Needs install (PySQLite_)
+metakit    Fastest(*)  Few   Needs install (metakit_)
+postgresql Fast        Many  Needs install/admin (psycopg_)
+mysql      Fast        Many  Needs install/admin (MySQLdb_)
+========== =========== ===== ==============================
+
+**sqlite** and **metakit**
+  These use the embedded database engines PySQLite_ and metakit_ to provide
+  very fast backends. They are not suitable for trackers which will have
+  many simultaneous users, but require much less installation and
+  maintenance effort than more scalable postgresql and mysql backends.
+  If you are choosing from these two, please select sqlite.
+**postgresql**
+  Backend for popular RDBMS PostgreSQL. You must read doc/postgresql.txt for
+  additional installation steps and requirements. You must also configure
+  the ``rdbms`` section of your tracker's ``config.ini``
+**mysql**
+  Backend for popular RDBMS MySQL. You must read doc/mysql.txt for additional
+  installation steps and requirements. You must also configure the ``rdbms``
+  section of your tracker's ``config.ini``
+
+You may defer your decision by setting your tracker up with the anydbm
+backend (which is guaranteed to be available) and switching to one of the
+other backends at any time using the instructions in the `administration
+guide`_.
+
+Regardless of which backend you choose, Roundup will attempt to initialise
+a new database for you when you run the roundup-admin "initialise" command.
+In the case of MySQL and PostgreSQL you will need to have the appropriate
+privileges to create databases.
+
+
+Configure a Web Interface
+-------------------------
+
+There are three web interfaces to choose from:
+
+1. `web server cgi-bin`_
+2. `stand-alone web server`_
+3. `Zope product - ZRoundup`_
+4. `Apache HTTP Server with mod_python`_
+
+You may need to give the web server user permission to access the tracker home
+- see the `UNIX environment steps`_ for information. You may also need to
+configure your system in some way - see `platform-specific notes`_.
+
+
+Web Server cgi-bin
+~~~~~~~~~~~~~~~~~~
+
+A benefit of using the cgi-bin approach is that it's the easiest way to
+restrict access to your tracker to only use HTTPS. Access will be slower
+than through the `stand-alone web server`_ though.
+
+If your Python isn't install as "python" then you'll need to edit
+the ``roundup.cgi`` script to fix the first line.
+
+If you're using IIS on a Windows platform, you'll need to run this command
+for the cgi to work (it turns on the PATH_INFO cgi variable)::
+
+    adsutil.vbs set w3svc/AllowPathInfoForScriptMappings TRUE
+
+The ``adsutil.vbs`` file can be found in either ``c:\inetpub\adminscripts`` 
+or ``c:\winnt\system32\inetsrv\adminsamples\`` or
+``c:\winnt\system32\inetsrv\adminscripts\`` depending on your installation.
+
+More information about ISS setup may be found at:
+
+   http://support.microsoft.com/default.aspx?scid=kb%3Ben-us%3B276494
+
+Copy the ``cgi-bin/roundup.cgi`` file to your web server's ``cgi-bin``
+directory. You will need to configure it to tell it where your tracker home
+is. You can do this either:
+
+Through an environment variable
+  Set the variable TRACKER_HOMES to be a colon (":") separated list of
+  name=home pairs (if you're using apache, the SetEnv directive can do this)
+
+Directly in the ``roundup.cgi`` file itself
+  Add your instance to the TRACKER_HOMES variable as ``'name': 'home'``
+
+The "name" part of the configuration will appear in the URL and identifies the
+tracker (so you may have more than one tracker per cgi-bin script). Make sure
+there are no spaces or other illegal characters in it (to be safe, stick to
+letters and numbers). The "name" forms part of the URL that appears in the
+tracker config "tracker :: web" variable, so make sure they match. The "home"
+part of the configuration is the tracker home directory.
+
+If you're using Apache, you can use an additional trick to hide the
+``.cgi`` extension of the cgi script. Place the ``roundup.cgi`` script
+wherever you want it to be, renamed it to just ``roundup``, and add a
+couple lines to your Apache configuration::
+ 
+ <Location /path/to/roundup>
+   SetHandler cgi-script
+ </Location>
+
+
+Stand-alone Web Server
+~~~~~~~~~~~~~~~~~~~~~~
+
+This approach will give you the fastest of the three web interfaces. You may
+investigate using ProxyPass or similar configuration in apache to have your
+tracker accessed through the same URL as other systems.
+
+The stand-alone web server is started with the command ``roundup-server``. It
+has several options - display them with ``roundup-server -h``.
+
+The tracker home configuration is similar to the cgi-bin - you may either edit
+the script to change the TRACKER_HOMES variable or you may supply the
+name=home values on the command-line after all the other options.
+
+To make the server run in the background, use the "-d" option, specifying the
+name of a file to write the server process id (pid) to.
+
+
+Zope Product - ZRoundup
+~~~~~~~~~~~~~~~~~~~~~~~
+
+ZRoundup installs as a regular Zope product. Copy the ZRoundup directory to
+your Products directory either in INSTANCE_HOME/Products or the Zope
+code tree lib/python/Products.
+
+When you next (re)start up Zope, you will be able to add a ZRoundup object
+that interfaces to your new tracker.
+
+Apache HTTP Server with mod_python
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+`Mod_python`_ is an `Apache`_ module that embeds the Python interpreter
+within the server.  Running Roundup this way is much faster than all
+above options and, like `web server cgi-bin`_, allows to use HTTPS
+protocol.  The drawback is that this setup is more complicated.
+
+The following instructions were tested on apache 2.0 with mod_python 3.1.
+If you are using older versions, your mileage may vary.
+
+Mod_python uses OS threads.  If your apache was built without threads
+(quite commonly), you must load the threading library to run mod_python.
+This is done by setting ``LD_PRELOAD`` to your threading library path
+in apache ``envvars`` file.  Example for gentoo linux (``envvars`` file
+is located in ``/usr/lib/apache2/build/``)::
+
+  LD_PRELOAD=/lib/libpthread.so.0
+  export LD_PRELOAD
+
+Example for FreeBSD (``envvars`` is in ``/usr/local/sbin/``)::
+
+  LD_PRELOAD=/usr/lib/libc_r.so
+  export LD_PRELOAD
+
+Next, you have to add Roundup trackers configuration to apache config.
+Roundup apache interface uses two options specified with ``PythonOption``
+directives:
+
+  TrackerHome:
+    defines the tracker home directory - the directory that was specified
+    when you did ``roundup-admin init``.  This option is required.
+
+  TrackerLaguage:
+    defines web user interface language.  mod_python applications do not
+    receive OS environment variables in the same way as command-line
+    programs, so the language cannot be selected by setting commonly
+    used variables like ``LANG`` or ``LC_ALL``.  ``TrackerLanguage``
+    value has the same syntax as values of these environment variables.
+    This option may be omitted.
+
+  TrackerDebug:
+    run the tracker in debug mode.  Setting this option to ``yes`` or
+    ``true`` has the same effect as running ``roundup-server -t debug``:
+    the database schema and used html templates are rebuilt for each
+    HTTP request.  Values ``no`` or ``false`` mean that all html
+    templates for the tracker are compiled and the database schema is
+    checked once at startup.  This is the default behaviour.
+
+  TrackerTiming:
+    has nearly the same effect as environment variable ``CGI_SHOW_TIMING``
+    for standalone roundup server.  The difference is that setting this
+    option to ``no`` or ``false`` disables timings display.  Value
+    ``comment`` writes request handling times in html comment, and
+    any other non-empty value makes timing report visible.  By default,
+    timing display is disabled.
+
+In the following example we have two trackers set up in
+``/var/db/roundup/support`` and ``var/db/roundup/devel`` and accessed
+as ``https://my.host/roundup/support/`` and ``https://my.host/roundup/devel/``
+respectively.  Having them share same parent directory allows us to
+reduce the number of configuration directives.  Support tracker has
+russian user interface.  The other tracker (devel) has english user
+interface (default).
+
+Static files from ``html`` directory are served by apache itself - this
+is quickier and generally more robust than doing that from python.
+Everything else is aliased to dummy (non-existing) ``py`` file,
+which is handled by mod_python and our roundup module.
+
+Example mod_python configuration::
+
+    #################################################
+    # Roundup Issue tracker
+    #################################################
+    # enable Python optimizations (like 'python -O')
+    PythonOptimize On
+    # let apache handle static files from 'html' directories
+    AliasMatch /roundup/(.+)/@@file/(.*) /var/db/roundup/$1/html/$2
+    # everything else is handled by roundup web UI
+    AliasMatch /roundup/([^/]+)/(?!@@file/)(.*) /var/db/roundup/$1/dummy.py/$2
+    # roundup requires a slash after tracker name - add it if missing
+    RedirectMatch permanent /roundup/([^/]+)$ /roundup/$1/
+    # common settings for all roundup trackers
+    <Directory /var/db/roundup/*>
+      Order allow,deny
+      Allow from all
+      AllowOverride None
+      Options None
+      AddHandler python-program .py
+      PythonHandler roundup.cgi.apache
+      # uncomment the following line to see tracebacks in the browser
+      # (note that *some* tracebacks will be displayed anyway)
+      #PythonDebug On
+    </Directory>
+    # roundup tracker homes
+    <Directory /var/db/roundup/support>
+      PythonOption TrackerHome /var/db/roundup/support
+      PythonOption TrackerLanguage ru
+    </Directory>
+    <Directory /var/db/roundup/devel>
+      PythonOption TrackerHome /var/db/roundup/devel
+    </Directory>
+
+
+Configure an Email Interface
+----------------------------
+
+If you don't want to use the email component of Roundup, then remove the
+"``nosyreaction.py``" module from your tracker "``detectors``" directory.
+
+See `platform-specific notes`_ for steps that may be needed on your system.
+
+There are three supported ways to get emailed issues into the
+Roundup tracker.  You should pick ONE of the following, all
+of which will continue my example setup from above:
+
+As a mail alias pipe process 
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Set up a mail alias called "issue_tracker" as (include the quote marks):
+"``|/usr/bin/python /usr/bin/roundup-mailgw <tracker_home>``"
+(substitute ``/usr/bin`` for wherever roundup-mailgw is installed).
+
+In some installations (e.g. RedHat 6.2 I think) you'll need to set up smrsh so
+sendmail will accept the pipe command. In that case, symlink
+``/etc/smrsh/roundup-mailgw`` to "``/usr/bin/roundup-mailgw``" and change
+the command to::
+
+    |roundup-mailgw /opt/roundup/trackers/support
+ 
+To test the mail gateway on unix systems, try::
+
+    echo test |mail -s '[issue] test' support at YOUR_DOMAIN_HERE
+
+As a custom router/transport using a pipe process (Exim4 specific)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The following configuration snippets for `Exim 4`_ configuration
+implement a custom router & transport to accomplish mail delivery to
+roundup-mailgw. A configuration for Exim3 is similar but not
+included, since Exim3 is considered obsolete.
+
+.. _Exim 4: http://www.exim.org/
+
+This configuration is similar to the previous section, in that it uses
+a pipe process. However, there are advantages to using a custom
+router/transport process, if you are using Exim.
+
+* This avoids privilege escalation, since otherwise the pipe process
+  will run as the mail user, typically mail. The transport can be
+  configured to run as the user appropriate for the task at hand. In the
+  transport described in this section, Exim4 runs as the unprivileged
+  user ``roundup``.
+
+* Separate configuration is not required for each tracker
+  instance. When a email arrives at the server, Exim passes it through
+  the defined routers. The roundup_router looks for a match with one of
+  the roundup directories, and if there is one it is passed to the
+  roundup_transport, which uses the pipe process described in the
+  previous section (`As a mail alias pipe process`_).
+
+The matching is done in the line::
+
+  require_files = /usr/bin/roundup-mailgw:ROUNDUP_HOME/$local_part/schema.py
+
+The following configuration has been tested on Debian Sarge with
+Exim4. 
+
+.. note::
+  Note that the Debian Exim4 packages don't allow pipes in alias files
+  by default, so the method described in the section `As a mail alias
+  pipe process`_ will not work with the default configuration. However,
+  the method described in this section does. See the discussion in
+  ``/usr/share/doc/exim4-config/README.system_aliases`` on any Debian
+  system with Exim4 installed.
+
+  For more Debian-specific information, see suggested addition to
+  README.Debian in
+  http://bugs.debian.org/cgi-bin/bugreport.cgi?bug=343283, which will
+  hopefully be merged into the Debian package eventually.
+
+This config makes a few assumptions:
+
+* That the mail address corresponding to the tracker instance has the
+  same name as the directory of the tracker instance, i.e. the mail
+  interface address corresponding to a Roundup instance called
+  ``/var/lib/roundup/trackers/mytracker`` is ``mytracker at your.host``.
+
+* That (at least) all the db subdirectories of all the tracker
+  instances (ie. ``/var/lib/roundup/trackers/*/db``) are owned by the same
+  user, in this case, 'roundup'.
+
+* That if the ``schema.py`` file exists, then the tracker is ready for
+  use. Another option is to use the ``config.ini`` file, but this recently
+  changed (in 0.8) from ``config.py``.
+
+Macros for Roundup router/transport. Should be placed in the macros
+section of the Exim4 config::
+
+  # Home dir for your Roundup installation
+  ROUNDUP_HOME=/var/lib/roundup/trackers
+
+  # User and group for Roundup.
+  ROUNDUP_USER=roundup
+  ROUNDUP_GROUP=roundup
+
+Custom router for Roundup. This will (probably) work if placed at the
+beginning of the router section of the Exim4 config::
+
+  roundup_router:
+      driver = accept
+      # The config file config.ini seems like a more natural choice, but the
+      # file config.py was replaced by config.ini in 0.8, and schema.py needs
+      # to be present too.
+      require_files = /usr/bin/roundup-mailgw:ROUNDUP_HOME/$local_part/schema.py
+      transport = roundup_transport
+
+Custom transport for Roundup. This will (probably) work if placed at
+the beginning of the router section of the Exim4 config::
+
+  roundup_transport:
+      driver = pipe
+      command = /usr/bin/python /usr/bin/roundup-mailgw ROUNDUP_HOME/$local_part/
+      current_directory = ROUNDUP_HOME
+      home_directory = ROUNDUP_HOME
+      user = ROUNDUP_USER
+      group = ROUNDUP_GROUP
+
+As a regular job using a mailbox source
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Set ``roundup-mailgw`` up to run every 10 minutes or so. For example
+(substitute ``/usr/bin`` for wherever roundup-mailgw is installed)::
+
+  0,10,20,30,40,50 * * * * /usr/bin/roundup-mailgw /opt/roundup/trackers/support mailbox <mail_spool_file>
+
+Where the ``mail_spool_file`` argument is the location of the roundup submission
+user's mail spool. On most systems, the spool for a user "issue_tracker"
+will be "``/var/mail/issue_tracker``".
+
+As a regular job using a POP source
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+To retrieve from a POP mailbox, use a *cron* entry similar to the mailbox
+one (substitute ``/usr/bin`` for wherever roundup-mailgw is
+installed)::
+
+  0,10,20,30,40,50 * * * * /usr/bin/roundup-mailgw /opt/roundup/trackers/support pop <pop_spec>
+
+where pop_spec is "``username:password at server``" that specifies the roundup
+submission user's POP account name, password and server.
+
+On windows, you would set up the command using the windows scheduler.
+
+As a regular job using an IMAP source
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+To retrieve from an IMAP mailbox, use a *cron* entry similar to the
+POP one (substitute ``/usr/bin`` for wherever roundup-mailgw is
+installed)::
+
+  0,10,20,30,40,50 * * * * /usr/bin/roundup-mailgw /opt/roundup/trackers/support imap <imap_spec>
+
+where imap_spec is "``username:password at server``" that specifies the roundup
+submission user's IMAP account name, password and server. You may
+optionally include a mailbox to use other than the default ``INBOX`` with
+"``imap username:password at server mailbox``".
+
+If you have a secure (ie. HTTPS) IMAP server then you may use ``imaps``
+in place of ``imap`` in the command to use a secure connection.
+
+As with the POP job, on windows, you would set up the command using the
+windows scheduler.
+
+
+UNIX Environment Steps
+----------------------
+
+Each tracker ideally should have its own UNIX group, so create
+a UNIX group (edit ``/etc/group`` or your appropriate NIS map if
+you're using NIS).  To continue with my examples so far, I would
+create the UNIX group 'support', although the name of the UNIX
+group does not have to be the same as the tracker name.  To this
+'support' group I then add all of the UNIX usernames who will be
+working with this Roundup tracker.  In addition to 'real' users,
+the Roundup email gateway will need to have permissions to this
+area as well, so add the user your mail service runs as to the
+group (typically "mail" or "daemon").  The UNIX group might then
+look like::
+
+     support:*:1002:jblaine,samh,geezer,mail
+
+If you intend to use the web interface (as most people do), you
+should also add the username your web server runs as to the group.
+My group now looks like this::
+
+     support:*:1002:jblaine,samh,geezer,mail,apache
+
+The tracker "db" directory should be chmod'ed g+sw so that the group can
+write to the database, and any new files created in the database will be owned
+by the group.
+
+If you're using the mysql or postgresql backend then you'll need to ensure
+that the tracker user has appropriate permissions to create/modify the
+database. If you're using roundup.cgi, the apache user needs permissions
+to modify the database.  Alternatively, explicitly specify a database login
+in ``rdbms`` -> ``user`` and ``password`` in ``config.ini``.
+
+An alternative to the above is to create a new user who has the sole
+responsibility of running roundup. This user:
+
+1. runs the CGI interface daemon
+2. runs regular polls for email
+3. runs regular checks (using cron) to ensure the daemon is up
+4. optionally has no login password so that nobody but the "root" user
+   may actually login and play with the roundup setup.
+
+
+Additional Language Codecs
+--------------------------
+
+If you intend to send messages to Roundup that use Chinese, Japanese or
+Korean encodings the you'll need to obtain CJKCodecs from
+http://cjkpython.berlios.de/
+
+
+
+Maintenance
+===========
+
+Read the separate `administration guide`_ for information about how to
+perform common maintenance tasks with Roundup.
+
+
+Upgrading
+=========
+
+Read the separate `upgrading document`_, which describes the steps needed to
+upgrade existing tracker trackers for each version of Roundup that is
+released.
+
+
+Further Reading
+===============
+
+If you intend to use Roundup with anything other than the default
+templates, if you would like to hack on Roundup, or if you would
+like implementation details, you should read `Customising Roundup`_.
+
+
+Running Multiple Trackers
+=========================
+
+Things to think about before you jump off the deep end and install
+multiple trackers, which involve additional URLs, user databases, email
+addresses, databases to back up, etc.
+
+1. Do you want a tracker per product you sell/support? You can just add
+   a new property to your issues called Product, and filter by that. See
+   the customisation example `adding a new field to the classic schema`_.
+2. Do you want to track internal software development issues and customer
+   support issues separately? You can just set up an additional "issue"
+   class called "cust_issues" in the same tracker, mimicing the normal
+   "issue" class, but with different properties. See the customisation
+   example `tracking different types of issues`_.
+
+
+Platform-Specific Notes
+=======================
+
+Windows command-line tools
+--------------------------
+
+To make the command-line tools accessible in Windows, you need to update
+the "Path" environment variable in the Registry via a dialog box.
+
+On Windows 2000 and later:
+
+1) Press the "Start" button.
+2) Choose "Settings"
+3) Choose "Control Panel"
+4) Choose "System"
+5) Choose "Advanced"
+6) Choose "Environmental Variables"
+7) Add: "<dir>\Scripts" to the "Path" environmental variable.
+
+Where <dir> in 7) is the root directory (e.g., ``C:\Python22\Scripts``)
+of your Python installation.
+
+I understand that in XP, 2) above is not needed as "Control
+Panel" is directly accessible from "Start".
+
+I do not believe this is possible to do in previous versions of Windows.
+
+
+Windows Server
+--------------
+
+To have the Roundup web server start up when your machine boots up, set the
+following up in Scheduled Tasks (note, the following is for a cygwin setup):
+
+Run
+ ``c:\cygwin\bin\bash.exe -c "roundup-server TheProject=/opt/roundup/trackers/support"``
+Start In
+ ``C:\cygwin\opt\roundup\bin``
+Schedule
+ At System Startup
+
+To have the Roundup mail gateway run periodically to poll a POP email address,
+set the following up in Scheduled Tasks:
+
+Run
+ ``c:\cygwin\bin\bash.exe -c "roundup-mailgw /opt/roundup/trackers/support pop roundup:roundup at mail-server"``
+Start In
+ ``C:\cygwin\opt\roundup\bin``
+Schedule
+ Every 10 minutes from 5:00AM for 24 hours every day
+ Stop the task if it runs for 8 minutes
+
+
+Sendmail smrsh
+--------------
+
+If you use Sendmail's ``smrsh`` mechanism, you will need to tell
+smrsh that roundup-mailgw is a valid/trusted mail handler
+before it will work.
+
+This is usually done via the following 2 steps:
+
+1. make a symlink in ``/etc/smrsh`` called ``roundup-mailgw``
+   which points to the full path of your actual ``roundup-mailgw``
+   script.
+
+2. change your alias to ``"|roundup-mailgw <tracker_home>"``
+
+
+Linux
+-----
+
+Make sure you read the instructions under `UNIX environment steps`_.
+
+
+Solaris
+-------
+
+You'll need to build Python.
+
+Make sure you read the instructions under `UNIX environment steps`_.
+
+
+Problems? Testing your Python...
+================================
+
+.. note::
+   The ``run_tests.py`` script is packaged in Roundup's source distribution
+   - users of the Windows installer, other binary distributions or
+   pre-installed Roundup will need to download the source to use it.
+
+Once you've unpacked roundup's source, run ``python run_tests.py`` in the
+source directory and make sure there are no errors. If there are errors,
+please let us know!
+
+If the above fails, you may be using the wrong version of python. Try
+``python2 run_tests.py``. If that works, you will need to substitute
+``python2`` for ``python`` in all further commands you use in relation to
+Roundup -- from installation and scripts.
+
+
+-------------------------------------------------------------------------------
+
+Back to `Table of Contents`_
+
+Next: `User Guide`_
+
+.. _`table of contents`: index.html
+.. _`user guide`: user_guide.html
+.. _`roundup specification`: spec.html
+.. _`tracker configuration`: customizing.html#tracker-configuration
+.. _`customisation documentation`: customizing.html
+.. _`Adding a new field to the classic schema`:
+   customizing.html#adding-a-new-field-to-the-classic-schema
+.. _`Tracking different types of issues`:
+   customizing.html#tracking-different-types-of-issues
+.. _`customising roundup`: customizing.html
+.. _`upgrading document`: upgrading.html
+.. _`administration guide`: admin_guide.html
+
+
+.. _External hyperlink targets:
+
+.. _apache: http://httpd.apache.org/
+.. _metakit: http://www.equi4.com/metakit/
+.. _mod_python: http://www.modpython.org/
+.. _MySQLdb: http://sourceforge.net/projects/mysql-python
+.. _Psycopg: http://initd.org/software/initd/psycopg
+.. _pysqlite: http://pysqlite.org/

Added: tracker/vendor/roundup/current/doc/mysql.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/mysql.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,66 @@
+=============
+MySQL Backend
+=============
+
+:version: $Revision: 1.12 $
+
+This notes detail the MySQL backend for the Roundup issue tracker.
+
+
+Prerequisites
+=============
+
+To use MySQL as the backend for storing roundup data, you also need 
+to install:
+
+1. MySQL RDBMS 4.0.16 or higher - http://www.mysql.com. Your MySQL
+   installation MUST support InnoDB tables (or Berkeley DB (BDB) tables
+   if you have no other choice). If you're running < 4.0.16 (but not <4.0)
+   then you'll need to use BDB to pass all unit tests. Edit the
+   ``roundup/backends/back_mysql.py`` file to enable DBD instead of InnoDB.
+2. Python MySQL interface - http://sourceforge.net/projects/mysql-python
+
+.. note::
+   The InnoDB implementation has a bug__ that Roundup tickles. See
+
+__ http://bugs.mysql.com/bug.php?id=1810
+
+
+Running the MySQL tests
+=======================
+
+Roundup tests expect an empty MySQL database. Two alternate ways to provide 
+this:
+
+1. If you have root permissions on the MySQL server, you can create 
+   the necessary database entries using the follwing SQL sequence. Use
+   ``mysql`` on the command line to enter::
+
+       CREATE DATABASE rounduptest;
+       USE rounduptest;
+       GRANT ALL PRIVILEGES ON rounduptest.* TO rounduptest at localhost
+            IDENTIFIED BY 'rounduptest';
+       FLUSH PRIVILEGES;
+
+2. If your administrator has provided you with database connection info, 
+   you can modify MYSQL_* constants in the file test/test_db.py with 
+   the correct values.
+
+The MySQL database should not contain any tables. Tests will not 
+drop the database with existing data.
+
+
+Showing MySQL who's boss
+========================
+
+If things ever get to the point where that test database is totally hosed,
+just::
+
+  $ su -
+  # /etc/init.d/mysql stop
+  # rm -rf /var/lib/mysql/rounduptest
+  # /etc/init.d/mysql start
+
+and all will be better (note that on some systems, ``mysql`` is spelt
+``mysqld``).
+

Added: tracker/vendor/roundup/current/doc/original_overview.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/original_overview.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,962 @@
+<!doctype html public "-//W3C//DTD HTML 4.0 Transitional//EN">
+<html><head>
+<title>Roundup: an Issue-Tracking System for Knowledge Workers</title>
+<link rev=made href="mailto:ping at lfw.org">
+</head><body>
+
+<table width="100%">
+<tr>
+
+<td align="left">
+<a href="http://www.software-carpentry.com">
+<img src="images/logo-software-carpentry-standard.png" alt="[Software Carpentry logo]" border="0">
+</a>
+</td>
+
+<td align="right">
+<table>
+<tr><td>
+<a href="http://www.acl.lanl.gov">
+<img src="images/logo-acl-medium.png" alt="[ACL Logo]" border="0">
+</a>
+</td></tr>
+<tr><td><hr></td></tr>
+<tr><td>
+<a href="http://www.codesourcery.com">
+<img src="images/logo-codesourcery-medium.png" alt="[CodeSourcery Logo]" border="0">
+</a>
+</td></tr>
+</table>
+</td>
+
+</tr>
+
+<tr>
+
+<td colspan="2"><em>
+Copyright (c) 2000 Ka-Ping Yee.  This material may
+be distributed only subject to the terms and conditions set forth in
+the Software Carpentry Open Publication License, which is available at:
+<center>
+<a href="http://www.software-carpentry.com/openpub-license.html">http://www.software-carpentry.com/openpub-license.html</a>
+</center>
+</em></td>
+
+</tr>
+</table>
+
+<p><hr><p>
+
+
+<h1 align=center>Roundup</h1>
+<h3 align=center>An Issue-Tracking System for Knowledge Workers</h3>
+<h4 align=center>Ka-Ping Yee</h4>
+<h4 align=center><a href="http://www.lfw.org/">lfw discorporated</a><br>
+<a href="mailto:ping at lfw.org">ping at lfw.org</a></h4>
+
+<!-- the following line will start a comment in lynx -soft_dquotes mode -->
+<p style="><!--">
+
+<p><hr>
+<h2>Contents</h2>
+
+<ul>
+<li><a href="#overview">Overview</a>
+<li><a href="#background">Background</a>
+    <ul>
+    <li><a href="#principles">Guiding Principles</a>
+    </ul>
+<li><a href="#data">Data Model</a>
+    <ul>
+    <li><a href="#hyperdb">The Hyperdatabase</a>
+    <li><a href="#rationale">Rationale</a>
+    <li><a href="#roundupdb">Roundup's Hyperdatabase</a>
+    <li><a href="#schema">The Default Schema</a>
+    </ul>
+<li><a href="#ui">User Interface</a>
+    <ul>
+    <li><a href="#discuss">Submission and Discussion (Nosy Lists)</a>
+    <li><a href="#edit">Editing (Templated UI)</a>
+    <li><a href="#browse">Browsing and Searching</a>
+    </ul>
+<li><a href="#devplan">Development Plan</a>
+<li><a href="#issues">Open Issues</a>
+<li><a href="#summary">Summary</a>
+<li><a href="#ack">Acknowledgements</a>
+</ul>
+
+<!-- this comment will end the comment started in lynx -soft_dquotes mode -->
+
+<p><hr>
+<h2><a name="overview">Overview</a></h2>
+
+<p>We propose an issue-tracking system called
+<em>Roundup</em>, which will manage a number of issues
+(with properties such as "description", "priority", and so on)
+and provide the ability to
+(a) submit new issues,
+(b) find and edit existing issues,
+and
+(c) discuss issues with other participants.
+The system will facilitate communication
+among the participants by managing discussions and
+notifying interested parties when issues are edited.
+
+<p>This design draws on experience from 
+<a href="http://www.lfw.org/ping/roundup.html">an existing
+implementation</a> which we will refer to
+as "the Roundup prototype".
+The graphical interface we have in mind will resemble
+<a href="http://www.lfw.org/ping/roundup-1.png">
+the main display of the prototype</a>.
+
+<p align=center>
+<a href="images/roundup-1.png">
+<img src="images/roundup.png" width=358 height=205 border=0 alt=""></a>
+
+<p><hr>
+<h2><a name="background">Background</a></h2>
+
+<p>A typical software project requires the management of
+many tasks, usually distributed among several collaborators.
+In fact, any project team
+could use a tool for sorting out and discussing all the
+relevant issues.  A common approach is to set up some kind
+of "to-do" list that people can share.
+
+<p>However, to address the overall problem we need much more
+than just a shared to-do list; we need to
+manage a growing body of knowledge and experience to help a
+team collaborate effectively on a project.  The issue-tracking
+tool becomes a nexus for communication: the Grand Central
+Station of the group intelligence.
+
+<p>The primary focus of this design is to help
+developers work together well, not to provide a customer
+service interface to the developers.  This is not to say that
+the design is to be made unsuitable for customers to use.
+Rather, it is assumed that many of the same qualities
+that are good for supporting development (see below)
+are also good for non-developers using the system.
+Additional niceties
+for providing a safe or simplified interface to clients are
+intentionally deferred for later consideration.
+
+<p>A good issue-tracking system should have at least the
+following properties:
+
+<p><table align=right width="40%" bgcolor="#808080"
+cellspacing=0 cellpadding=0 border=0><tr><td
+><table bgcolor="#e8e8e8" width="100%"
+cellspacing=0 cellpadding=5 border=0><tr><td
+><p><font color="#808080"><small>
+With a nod to the time-honoured computer science tradition
+of "filling in the fourth quadrant", we note that
+there are really four kinds of information flow
+going on here.  The three mentioned qualities
+really address the first three quadrants of this 2-by-2 matrix,
+respectively:
+
+<ol>
+<li>User push: a user submits information to the system.
+<li>User pull: a user queries for information from the system.
+<li>System push: the system sends information out to users.
+<li>System pull: the system solicits information from users.
+</ol>
+
+An example of the fourth kind of flow is voting.
+Voting isn't described in this design,
+but it should be noted as a potential enhancement.
+</small></font></td></tr></table></td></tr></table>
+
+<ol>
+<li><strong>Low barrier to participation.</strong>
+The usefulness of the tool depends entirely on the
+information people contribute to it.  It must be made
+as easy as possible to submit new issues and contribute
+information about existing issues.<p>
+
+<li><strong>Straightforward navigation.</strong>
+It should be easy for users to extract information they need
+from the system to direct their decisions and tasks.
+They should be able to get a decent overview of
+things as well as finding specific information when
+they know what they're after.<p>
+
+<li><strong>Controlled information flow.</strong>
+The users must have control over how much information and
+what information they get.  A common flaw of some issue-tracking
+systems is that they inundate users with so much useless
+e-mail that people avoid the system altogether.
+</ol>
+<br clear=all>
+
+<p><br>
+<h3><a name="principles">Guiding Principles</a></h3>
+
+<p><strong>Simplicity.</strong> It is a strong requirement
+that the tool be accessible and understandable.  It should
+be fairly obvious what different parts of the interface do,
+and the inner mechanisms should operate in ways that most
+users can easily predict.
+
+<p><strong>Efficiency.</strong>
+We aim to optimize for minimum effort to do the most common
+operations, and best use of resources like screen real estate
+to maximize the amount of information that we summarize and present.
+
+<p><strong>Generality.</strong> We try to avoid making
+unnecessary assumptions that would restrict the applicability
+of the tool.  For example, there is no reason why one might
+not also want to use this tool to manage a design process,
+non-software projects, or organizational decisions.
+
+<p><strong>Persistence.</strong> We
+prefer hiding or reclassifying information to deleting it.
+This helps support the collection of statistics later.
+If records are never destroyed, there is little danger
+in providing access to a larger community, and logging yields
+accountability, which may encourage better behaviour.
+
+<p><hr>
+<p><table align=right width="40%" bgcolor="#808080"
+cellspacing=0 cellpadding=0 border=0><tr><td
+><table bgcolor="#e8e8e8" width="100%"
+cellspacing=0 cellpadding=5 border=0><tr><td
+><font color="#808080"><small>
+Okay, enough ranting.  Let's get down to business.
+</small></font></td></tr></table></td></tr></table>
+<h2><a name="data">Data Model</a></h2>
+
+<p>Roundup stores a number of <em>items</em>, each of
+which can have several properties and an associated discussion.
+The properties can be used to classify or search for items.
+The discussion is a sequence of e-mail messages.
+Each item is identified by a unique number, and has
+an activity log which
+records the time and content of edits made on its properties.
+The log stays fairly small since the design intentionally
+provides only small data types as item properties, and
+encourages anything large to be attached to
+e-mail where it becomes part of the discussion.
+The next section explains how items are organized.
+
+<h3><a name="hyperdb">The Hyperdatabase</a></h3>
+
+<p><table align=right width="40%" bgcolor="#808080"
+cellspacing=0 cellpadding=0 border=0><tr><td
+><table bgcolor="#e8e8e8" width="100%"
+cellspacing=0 cellpadding=5 border=0><tr><td
+><font color="#808080"><small>
+In my opinion, forcing
+items into fixed categories is one of the most
+serious problems with the Roundup prototype.
+The hyperdatabase is an <em>experimental</em> attempt to
+address the problem of information organization,
+whose scope goes beyond just Roundup.
+</small></font></td></tr></table></td></tr></table>
+
+Often when classifying information we are
+asked to select exactly one of a number of categories
+or to fit it into a rigid hierarchy.  Yet things
+only sometimes fall into one category; often,
+a piece of information may be related to several concepts.
+
+For example, forcing each item into a single topic
+category is not just suboptimal but counterproductive:
+seekers of that
+item may expect to find it in a different category
+and conclude that the item is not present in the
+database -- which has them <em>worse</em> off
+than if the items were not categorized at all.
+
+<p>Some systems try to alleviate this problem by
+allowing nodes to appear at multiple locations
+in a tree, as with "aliases" or "symbolic links" in
+a filesystem, for example.  This does help somewhat,
+but we want to be even more flexible
+by allowing the
+organization of nodes into sets that may freely
+intersect.  Rather than putting each node at exactly
+one place in an overall "grand scheme", a node can
+belong to as many sets as are appropriate.
+
+If we choose to represent the sets themselves as nodes
+and set membership as a link between nodes,
+we're now ready to present the definition of a
+hyperdatabase.
+
+<p><table align=right width="40%" bgcolor="#808080"
+cellpadding=0 cellspacing=0 border=0><tr><td
+><table bgcolor="#e8e8e8" width="100%"
+cellspacing=0 cellpadding=5 border=0><tr><td
+><font color="#808080"><small>
+Perhaps it's too pretentious a name?
+You could say this is just like an object database.
+The hyperdatabase is hardly much of an invention; the
+intent is merely to emphasize querying on links
+rather than properties.
+(I haven't heard of this being done with
+object databases before, but plead ignorance if
+there's already a good name for this idea.)
+</small></font></td></tr></table></td></tr></table>
+A <em>hyperdatabase</em> is a collection of <em>nodes</em>
+that may be hyperlinked to
+each other (hence the name "hyperdatabase").
+Each node carries a collection of key-value pairs,
+where some of the values may be links to other nodes.
+Any node may have an arbitrary number of outgoing and
+incoming links.  Hyperdatabases are able to efficiently
+answer queries such as "what nodes link to this node?"
+and "what nodes does this node link to?"
+
+<h3><a name="rationale">Rationale</a></h3>
+
+<p>There are several reasons for building our
+own kind of database for Roundup rather than using an existing one.
+
+Requiring the installation of a full-blown third-party
+SQL database system would probably deter many potential
+users from attempting to set up Roundup;
+yet a real relational database would be too
+complicated to implement on our own.
+
+On the other hand, a hyperdatabase can be implemented fairly easily
+using one of the Python DBM modules, so we can
+take the "batteries-included" approach and provide it
+as part of the system.  It's easier to build and understand
+than a true relational database (in accordance with our guiding
+principle of <em>simplicity</em>), but provides
+most of the query functionality we want.
+
+<p>A hyperdatabase is well suited for finding the intersection
+of a number of sets in which items belong.  We expect that
+most of the queries people want to do will be of this
+form, rather than complicated SQL queries.  For example, a
+typical request might be
+"show me all critical items related to security".
+The ability to store arbitrary key-value pairs and links
+on nodes gives it more flexibility than an RDBMS.
+
+Users are not going to be making thousands of queries
+per second, so it makes sense to optimize for simplicity
+and flexibility rather than performance.
+
+<p align=center><img src="images/hyperdb.png" width=433 height=352 alt=""></a>
+
+
+<h3><a name="roundupdb">Roundup's Hyperdatabase</a></h3>
+
+<p>For our application, we store each item as a node in a
+hyperdatabase.  The item's properties are stored
+as key-value pairs on its node.
+Four types of properties are allowed:
+<em>string</em>, <em>date</em>,
+<em>choice</em>, and <em>reference</em>.
+
+<p>The <em>string</em> type is for short, free-form strings.
+String properties are not intended to contain large
+amounts of text, and it is recommended that they be presented
+as one-line fields to encourage brevity.
+
+<p>The <em>date</em> type is for calendar dates and times.
+
+<p>The <em>choice</em> type denotes a single selection
+from a number of options.  A <em>choice</em> property
+entails a link from the node possessing the property to
+the node representing the chosen option.
+
+<p>The <em>reference</em> type is for a list of links to any
+number of other nodes in the in the database.  A <em>reference</em>
+property, for example, can be used to refer to related items
+or topic categories relevant to an item.
+
+<p>For Roundup, all items have five properties
+that are not customizable:
+
+<ul>
+<li>a <em>string</em> property named <strong>description</strong>
+<li>a <em>reference</em> property named <strong>superseder</strong>
+<li>a <em>reference</em> property named <strong>nosy</strong>
+<li>a <em>date</em> property named <strong>creation</strong>
+<li>a <em>date</em> property named <strong>activity</strong>
+</ul>
+
+<p>The <strong>description</strong> property is a short
+one-line description of the item.
+The detailed description can go in the
+first e-mail message of the item's discussion spool.
+
+<p>The <strong>superseder</strong> property is used to 
+support the splitting, joining, or replacing of items.
+When several items need to be
+joined into a single item, all the old items
+link to the new item in their <strong>superseder</strong>
+property.
+When an item needs to be split apart, the item
+references all the new items in its <strong>superseder</strong>
+propety.
+We can easily list all active items just by checking
+for an empty <strong>superseder</strong> property, and trace
+the path of an item's origins by querying the hyperdatabase
+for links.
+
+<p>The <strong>nosy</strong> property contains a list of
+the people who are interested in an item.  This
+mechanism is explained in
+<a href="#discuss">the section on Nosy Lists</a>.
+
+<p>The <strong>creation</strong> property records the
+item's creation time.  The <strong>activity</strong>
+property records the last time that the item was edited or
+a mail message was added to its discussion spool.  These two
+properties are managed by Roundup and are not available to
+be edited like other properties.
+
+<p>Users of the system are also represented by nodes in the
+hyperdatabase, containing properties
+like the user's e-mail address, login name, and password.
+
+<h3><a name="schema">The Default Schema</a></h3>
+
+<p><table align=right width="40%" bgcolor="#808080"
+cellpadding=0 cellspacing=0 border=0><tr><td
+><table bgcolor="#e8e8e8" width="100%"
+cellspacing=0 cellpadding=5 border=0><tr><td
+><font color="#808080"><small>
+Roundup could be distributed with a few
+suggested schemas for different purposes.
+One possible enhancement to the
+software-development schema is
+a <em>reference</em> property
+named <strong>implements</strong> for connecting
+development items to design requirements which
+they satisfy, which should
+be enough to provide basic support for
+<a href="http://software-carpentry.codesourcery.com/lists/sc-discuss/msg00046.html">traceability</a>.
+Clearly there is also potential for adding
+properties for related source files, check-ins,
+test results, regression tests for resolved items,
+and so on, though these have not yet been
+sufficiently well thought out to specify here.
+</small></font></td></tr></table></td></tr></table>
+<p>It is hoped that the hyperdatabase together with the
+specializations mentioned above for Roundup will be
+applicable in a variety of situations
+(in accordance with our guiding principle of <em>generality</em>).
+
+<p>To address the problem at hand, we need
+a specific schema for items applied particularly to software development.
+Again, we are trying to keep the schema simple: too many
+options make it tougher for someone to make a good choice.
+The schema is written here in the same form that it would
+appear in a configuration file.
+<br clear=all>
+
+<pre>
+    fixer = Reference()             # people who will fix the problem
+
+    topic = Reference()             # relevant topic keywords
+
+    priority = Choice("critical",   # panic: work is stopped!
+                      "urgent",     # important, but not deadly
+                      "bug",        # lost work or incorrect results
+                      "feature",    # want missing functionality
+                      "wish")       # avoidable bugs, missing conveniences
+
+    status = Choice("unread",       # submitted but no action yet
+                    "deferred",     # intentionally set aside
+                    "chatting",     # under review or seeking clarification
+                    "need-eg",      # need a reproducible example of a bug
+                    "in-progress",  # understood; development in progress
+                    "testing",      # we think it's done; others, please test
+                    "done-cbb",     # okay for now, but could be better
+                    "resolved")     # fix has been released
+</pre>
+
+<p>The <strong>fixer</strong> property assigns
+responsibility for an item to a person or a list of people.
+The <strong>topic</strong> property places the
+item in an arbitrary number of relevant topic sets (see
+<a href="#browse">the section on Browsing and Searching</a>).
+
+<p>As previously mentioned, each item gets an activity log.
+Whenever a property on an item is changed, the log
+records the time of the change, the user making the change,
+and the old and new values of the property.  This permits
+the later gathering of statistics (for example, the average time
+from submission to resolution).
+
+<p>We do not specify or enforce a state transition graph,
+since making the system rigid in that fashion is probably more
+trouble than it's worth.
+Experience has shown that there are probably
+two convenient automatic state transitions:
+
+<ul>
+<li>from <strong>unread</strong> to <strong>chatting</strong>
+when e-mail is written about an item
+<li>from <strong>testing</strong> to <strong>resolved</strong>
+when a new release of the software is made
+</ul>
+
+Beyond these, in accordance with our principle of <em>generality</em>,
+we allow access to the hyperdatabase
+API so that scripts can automate transitions themselves or
+be triggered by changes in the database.
+
+<p><hr>
+<h2><a name="ui">User Interface</a></h2>
+
+<p>Roundup provides its services through two main interfaces:
+e-mail and the Web.
+This division is chosen to optimize the most common tasks.
+
+<p>E-mail is best suited for
+the submission of new items since most people are most comfortable
+with composing long messages in their own favourite e-mail client.
+E-mail also permits them to mention URLs or attach files relevant
+to their submission.  Indeed, in many cases people are already
+used to making requests by sending e-mail to a mailing list
+of people; they can do exactly the same thing to use Roundup
+without even thinking about it.
+Similarly, people are already
+familiar with holding discussions in e-mail, and plenty of
+valuable usage conventions and software tools already exist for that medium.
+
+<p>The Web, on the other hand, is best suited for summarizing
+and seeking information, because it can present an interactive
+overview of items.  Since the Web has forms, it's also
+the best place to edit items.
+
+<h3><a name="discuss">Submission and Discussion</a></h3>
+
+<p><table align=right width="40%" bgcolor="#808080" cellpadding=0 border=0
+><tr><td><table bgcolor="#e8e8e8" width="100%" cellspacing=0 cellpadding=5
+border=0><tr><td><font color="#808080"><small>
+Nosy lists have actually been tried in practice,
+and their emergent properties have
+turned out to be very effective.
+They are one of the key strengths of the Roundup prototype,
+and often cause me to wonder if all mailing lists ought to work this way.
+Roundup could even replace Hypermail.
+</small></font></td></tr></table></td></tr></table>
+
+<p>The system needs an address for receiving mail
+and an address that forwards mail to all participants.
+Each item has its own list
+of interested parties, known as its <em>nosy list</em>.
+Here's how nosy lists work:
+
+<p><ol type="a">
+<li>New items are always submitted by sending an e-mail message
+to Roundup.  The "Subject:" field becomes the description
+of the new item.
+The message is saved in the mail spool of the new item,
+and copied to the list of all participants
+so everyone knows that a new item has been added.
+The new item's nosy list initially contains the submitter.
+
+<li>All e-mail messages sent by Roundup have their "Reply-To:"
+field set to Roundup's address, and have the item's
+number in the "Subject:" field.  Thus, any replies to the
+initial announcement and subsequent threads are all received
+by Roundup.  Roundup notes the item number in the "Subject:"
+field of each incoming message and appends the message
+to the appropriate spool.
+
+<li>Any incoming e-mail tagged with an item number is copied
+to all the people on the item's nosy list,
+and any users found in the "From:", "To:", or "Cc:" fields
+are automatically added to the nosy list.  Whenever a user
+edits an item's properties in the Web interface, they are
+also added to the nosy list.
+</ol>
+
+<p>The effect
+is like each item having its own little mailing list,
+except that no one ever has to worry about subscribing to
+anything.  Indicating interest in an issue is sufficient, and if you
+want to bring someone new into the conversation, all you need to do
+is Cc: a message to them.  It turns out that no one ever has to worry
+about unsubscribing, either: the nosy lists are so specific in scope
+that the conversation tends to die down by itself when the issue is
+resolved or people no longer find it sufficiently important.
+
+<p>Each nosy list is like an asynchronous chat room,
+lasting only a short time (typically five or ten messages)
+and involving a small group of people.
+However, that
+group is the <em>right</em> group of people:
+only those who express interest in an item in some way
+ever end up on
+the list, so no one gets spammed with mail they
+don't care about, and no one who <em>wants</em>
+to see mail about a particular item needs to be left
+out, for they can easily join in, and just as easily
+look at the mail spool on an item to catch up on any
+messages they might have missed.
+
+<p>We can take this a step further and
+permit users to monitor particular topics or
+classifications of items
+by allowing other kinds of nodes to
+also have their own nosy lists.
+For example, a manager could be on the
+nosy list of the priority value node for "critical", or a
+developer could be on the nosy list of the
+topic value node for "security".
+The recipients are then
+determined by the union of the nosy lists on the
+item and all the nodes it links to.
+
+<p>Using many small, specific mailing lists results
+in much more effective communication than one big list.
+Taking away the effort of subscribing and unsubscribing
+gives these lists the "feel" of being cheap and
+disposable.
+
+The transparent capture of the mail spool attached to each
+issue also yields a nice knowledge repository over time.
+
+
+<h3><a name="edit">Editing</a></h3>
+
+<p>
+<img src="images/edit.png" align=right width=171 height=471 alt="">
+Since Roundup is intended to support arbitrary user-defined
+schema for item properties, the editing interface must be
+automatically generated from the schema.  The configuration for
+Roundup will include a template describing how to lay out the
+properties to present a UI for inspecting and editing items.
+For example:
+
+<pre>
+    &lt;table width="100%"&gt;
+    &lt;tr&gt;&lt;td align=right&gt;Description:&lt;/td&gt;
+        &lt;td&gt;&lt;?property description size=70&gt;&lt;/td&gt;&lt;/tr&gt;
+    &lt;tr&gt;&lt;td align=right&gt;Status:&lt;/td&gt;
+        &lt;td&gt;&lt;?property status&gt;&lt;/td&gt;&lt;/tr&gt;
+    &lt;/table&gt;
+</pre>
+
+<p>To display the editing form for an item, Roundup substitutes
+an HTML form widget for each <tt>&lt;?property </tt>...<tt>&gt;</tt>
+tag, and transfers attributes
+(such as <tt>size=70</tt> in the above example)
+from the processing tag to the form widget's tag.
+Each type has its own appropriate editing widget:
+<ul>
+<li><em>string</em> properties appear as text fields
+<li><em>date</em> properties appear as text fields
+<li><em>choice</em> properties appear as selection lists
+<li><em>reference</em> properties appear as multiple-selection lists
+with a text field for adding a new option
+</ul>
+
+<p>We foresee the use of custom date fields for things like deadlines,
+so input fields for <em>date</em> properties should support some
+simple way of specifying relative dates (such as "three weeks from now").
+
+<p>The <strong>superseder</strong> property is a special case:
+although it is more efficient to store a <strong>superseder</strong>
+property in the superseded item, it makes more sense to provide
+a "supersedes" edit field on the superseding item.  So we need
+a special widget on items for this purpose (perhaps something
+as simple as a text field containing a comma-separated list of
+item numbers will do).  Links in the <strong>superseder</strong> property
+should appear on both the superseding and superseded items to
+facilitate navigating an item's pedigree.
+
+<p>After the editing widgets, the item inspection page shows
+a "note" text box and then a display of the messages in the
+discussion spool, like the Roundup prototype.  This field
+lets you enter a note explaining your change when you edit the
+item, and the note is included in the notification message that
+goes out to tell the interested parties on the nosy list of
+your edits.
+
+<h3><a name="browse">Browsing and Searching</a></h3>
+
+<p>The ideal we would like to achieve is to make searching as
+much like browsing as possible: the user simply clicks about
+on things that seem interesting, and the information narrows
+down comfortably until the goal is in sight.  This is preferable
+to trying to digest a screen filled with widgets and buttons
+or entering a search expression in some arcane algebraic syntax.
+
+<p><table align=right width="40%" bgcolor="#808080" cellpadding=0 border=0
+><tr><td><table bgcolor="#e8e8e8" width="100%" cellspacing=0 cellpadding=5
+border=0><tr><td><font color="#808080"><small>
+Though the generation of each page amounts to a database query,
+so that the underlying mechanism is still a series of queries and
+responses, the user interface never separates the query from
+the response, so the <em>experience</em> is one of stepwise
+refinement.
+</small></font></td></tr></table></td></tr></table>
+While a one-shot search may be appropriate when you're
+looking for a single item and you know exactly what you want, it's
+not very helpful when you want an overview of
+things ("Gee, there are a lot more high-priority items than
+there were last week!") or trying to do comparisons ("I have
+some time today, so who is busiest and could most use some help?")
+<br clear=all>
+
+<p>The browsing interface presents filtering
+functionality for each of the properties in the schema.  As with
+editing, the interface is generated from a template
+describing how to lay out the properties.
+Each type of property has its own appropriate filtering widget:
+<ul>
+<li><em>string</em> properties appear as text fields supporting
+case-insensitive substring match
+<li><em>date</em> properties appear as a text field with an
+option to choose dates after or before the specified date
+<li><em>choice</em> properties appear as a group of
+selectable options
+(the filter selects the <em>union</em> of the sets of items
+associated with the active options)
+<li><em>reference</em> properties appear as a group of
+selectable options
+(the filter selects the <em>intersection</em> of the sets of items
+associated with the active options)
+</ul>
+
+<p>For a <em>reference</em> property like <strong>topic</strong>,
+one possibility is to show, as hyperlinks, the keywords whose
+sets have non-empty intersections with the currently displayed set of
+items.  Sorting the keywords by popularity seems
+reasonable.  Clicking on a keyword then narrows both the list of items
+and the list of keywords.  This gives some of the feel of walking
+around a directory tree -- but without the restriction of having
+to select keywords in a particular hierarchical order, and without
+the need to travel all the way to the leaves of the tree before
+any items are visible.
+
+<p>Below the filtering form is a listing of items, with their
+properties displayed in a table.  Rows in the table can also be
+generated from a template, as with the editing interface.
+This listing is the central overview of the system, and it
+should aim to maximize the density of
+useful information in accordance with our guiding principle of
+<em>efficiency</em>.
+For example, 
+<a href="http://www.lfw.org/ping/bugzilla-4.gif">Bugzilla
+initially displays seven or eight items of the index</a>, but only
+after the user has 
+<a href="http://www.lfw.org/ping/bugzilla-1.gif">waded</a>
+through
+<a href="http://www.lfw.org/ping/bugzilla-2.gif">three</a>
+bewildering
+<a href="http://www.lfw.org/ping/bugzilla-3.gif">screens</a> of
+form widgets.
+<a href="http://www.lfw.org/ping/jitterbug-1.gif">Jitterbug can't
+even fit any items at all in the first screenful</a>, as it's
+taken up by artwork and adminstrative debris.  In contrast,
+<a href="http://www.lfw.org/ping/roundup-1.gif">in the
+Roundup prototype,
+25 high-priority issues are immediately visible</a>, with
+most of the screen space devoted to their descriptions.  
+Colour indicates
+the status of each item to help the eye sift through the index quickly.
+
+<p>In both Jitterbug and Bugzilla, items are sorted by default by ID,
+a meaningless field.  Sorting by ID puts the issues in order by
+ascending submission date, which banishes recent issues far away
+at the bottom of the list.
+The Roundup prototype sorts items
+in sections by priority, and then within sections by the date
+of last activity.  This reveals at a glance where discussion is
+most active, and provides an easy way for anyone to move an issue
+up in the list.
+
+<p>The page produced by a given set of browsing options constitutes
+a <em>view</em>.  The options should all be part of the query
+parameters in the URL so that views may be bookmarked.  A view
+specifies:
+
+<ul>
+<li>search strings for string properties
+<li>date ranges for date properties
+<li>acceptable values for choice properties
+<li>required values for reference properties
+<li>one or more sort keys
+<li>a list of properties for which to display filtering widgets
+</ul>
+
+<p>On each sort key there is the option to use sections -- that is,
+instead of making the property's value a column of the table, each
+possible value for the property
+is displayed at the top of a section and all the items having
+that value for that property are grouped underneath.  This avoids
+wasting screen space with redundant information.
+
+<p>We propose that our default view should be:
+
+<ul>
+<li>all options on for <strong>priority</strong> and <strong>fixer</strong>
+<li>all options on except "resolved" for <strong>status</strong>
+<li>no options on for <strong>topic</strong>
+<li>primary sort by <strong>priority</strong> in sections
+<li>secondary sort by decreasing <strong>activity</strong> date
+</ul>
+
+<p>The starting URL for Roundup should immediately present the listing of
+items generated by this default view, with no
+preceding query screen.
+
+<p><hr>
+<h2><a name="devplan">Development Plan</a></h2>
+
+<p>The hyperdatabase is clearly a separable component which
+can be developed and tested independently to an API specification.
+
+<p>As soon as the API to the hyperdatabase is nailed down,
+the implementation of the Roundup database layer
+on top of the hyperdatabase can begin.
+(This refers to the data types and five fixed properties
+specific to Roundup.)  This layer can also be tested separately.
+
+<p>When the interface to the Roundup hyperdatabase is ready,
+development can begin on the user interface.  The mail handler
+and the Web interface can be developed in parallel and mostly
+independently of each other.
+
+<p>The mail handler can be set up for testing fairly easily:
+mail messages on its standard input can be synthesized;
+its output is outgoing mail, which can be
+captured by replacing the implementation of the
+"send mail" function; and its side effects appear in the
+hyperdatabase, which has a Python API.
+
+<p>The Web interface is not easily testable in its entirety,
+though the most important components of it can be unit tested,
+such as the component that translates a view specification
+into a list of items for display, and
+the component that performs replacements on templates
+to produce an editing or filtering interface.
+
+<p><hr>
+<h2><a name="issues">Open Issues</a></h2>
+
+<p>The description of the hyperdatabase above avoids some
+issues regarding node typing that need to be better specified.
+It is conceivable that eventually Roundup
+could support multiple kinds of items with their own schemas.
+
+<p>To permit integration with external tools, it is probably
+a good idea to provide a command-line tool that exposes the
+hyperdatabase API.  This tool will be left for a later phase
+of development and so isn't specified in detail here.
+
+<p>Generating the user interface from a template is like
+applying an XSL stylesheet to XML, and if there's a standard
+Python module for performing these transformations, we could
+use XML instead.
+
+<p>More thinking is needed to determine the best filtering
+interface for <em>reference</em> properties.
+The proposed interface works well for topic keywords, but
+it isn't clear what to do when there are too many keywords
+to display them all.
+
+<p>There has been a variety of reactions to the hyperdatabase
+from reviewers: some like it, some are neutral, and some
+would prefer a "standard" RDBMS solution.
+For those in the latter camp, note
+that it's still possible to build the Roundup database layer
+around an RDBMS if we really need to.  The rest of the design, in
+particular the "nosy list" mechanism, remains intact.
+
+<p>The possibility of malice by registered users has been disregarded.
+The system is intended to be used by a co-operative group.
+
+<p>This design tries to address as many as possible of the
+suggested requirements mentioned on
+<a href="http://software-carpentry.codesourcery.com/sc_track">the contest page</a>:
+
+<ul>
+<li>configuring states: Edit the schema.
+<li>setting state transition rules: We don't enforce any rules.
+<li>assigning responsibility: Set the <strong>fixer</strong> property.
+<li>splitting and joining: Use the <strong>superseder</strong> property.
+<li>hiding information: Add
+a property and a pre-defined view that filters on it.
+<li>secure protocols: Naturally HTTPS would be nice, though it's largely
+a webserver configuration issue; secure e-mail is not addressed.
+<li>archiving old issues: Tag them with a property.
+<li>identifying repeated issues: Use the <strong>superseder</strong> property.
+<li>connecting state changes to external operations: We provide an
+API to the database and the notification mechanism so it can be scripted.
+<li>non-Latin alphabets: Unicode in Python 1.6 will handle
+this for string properties, and we can leverage existing standards for
+internationalizing e-mail messages.
+<li>images and other binaries: Attach them to e-mail messages.
+<li>inspecting item state: Use the editing interface.
+<li>translation between system-dependent formats: This is not addressed.
+<li>performing searches: Use the browsing and filtering interface.
+<li>collecting statistics: Information is gathered in the activity log,
+though tools to summarize it are not described here.
+</ul>
+
+<p><hr>
+<h2><a name="summary">Summary</a></h2>
+
+<p>Roundup is an issue-tracking system that also functions as
+a communications center and a knowledge repository.  It combines
+the strengths of e-mail and the Web to try to provide the best
+possible user interaction.
+
+<ul>
+<li>The submission and discussion of items by e-mail, permitting
+participants to use an easy and familiar tool, achieves our goal
+of <em>low barrier to participation</em>.
+<li>The generic link-based structuring of data and use of
+incremental filtering rather than one-shot querying makes for
+<em>straightforward navigation</em>.
+<li>The use of <em>nosy lists</em> (a powerful replacement for
+e-mail discussion lists) to manage communication on
+a fine-grained level provides <em>controlled information flow</em>.
+</ul>
+
+<p>The use of a "hyperdatabase" as the core model for
+the knowledge repository gives us the flexibility to extend
+Roundup and apply it to a variety of domains by
+providing new item schemas and user-interface templates.
+
+<p>Roundup is self-contained and easy to set up, requiring
+only a webserver and a mailbox.  No one needs to be root to
+configure the webserver or to install database software.
+
+<p>This design is based on an existing deployed
+prototype which has proven its strengths and revealed its
+weaknesses in heavy day-to-day use by a real development team.
+
+<p><hr>
+<h2><a name="ack">Acknowledgements</a></h2>
+
+<p>My thanks are due to 
+Christina Heyl, Jesse Vincent, Mark Miller, Christopher Simons,
+Jeff Dunmall, Wayne Gramlich, and Dean Tribble
+for reviewing this paper and contributing their suggestions.
+
+<p><hr><p>
+
+<center>
+<table>
+<tr>
+<td>&nbsp;&nbsp;&nbsp;<a href="http://www.software-carpentry.com/index.html"><b>[Home]</b></a>&nbsp;&nbsp;&nbsp;</td>
+<td>&nbsp;&nbsp;&nbsp;<a href="http://www.software-carpentry.com/faq.html"><b>[FAQ]</b></a>&nbsp;&nbsp;&nbsp;</td>
+<td>&nbsp;&nbsp;&nbsp;<a href="http://www.software-carpentry.com/license.html"><b>[License]</b></a>&nbsp;&nbsp;&nbsp;</td>
+<td>&nbsp;&nbsp;&nbsp;<a href="http://www.software-carpentry.com/contest-rules.html"><b>[Rules]</b></a>&nbsp;&nbsp;&nbsp;</td>
+<td>&nbsp;&nbsp;&nbsp;<a href="http://www.software-carpentry.com/biblio.html"><b>[Resources]</b></a>&nbsp;&nbsp;&nbsp;</td>
+<td>&nbsp;&nbsp;&nbsp;<a href="http://www.software-carpentry.com/lists/"><b>[Archives]</b></a>&nbsp;&nbsp;&nbsp;</td>
+</tr>
+</table>
+</center>
+
+<p><hr>
+<center>
+Last modified 2001/04/06 11:50:59.9063 US/Mountain
+</center>
+</body></html>

Added: tracker/vendor/roundup/current/doc/overview.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/overview.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,603 @@
+=======================================================
+Roundup: an Issue-Tracking System for Knowledge Workers
+=======================================================
+
+:Authors: Ka-Ping Yee (original_), Richard Jones (implementation)
+
+.. _original: original_overview.html
+
+.. contents::
+
+
+Introduction
+============
+
+Roundup is an issue-tracking system called which will manage a
+number of issues (with properties such as "description", "priority",
+and so on) and provides the ability to:
+
+(a) submit new issues,
+(b) find and edit existing issues, and
+(c) discuss issues with other participants.
+
+Roundup facilitates communication among the participants by managing
+discussions and notifying interested parties when issues are edited.
+
+
+Background
+----------
+
+A typical software project requires the management of
+many tasks, usually distributed among several collaborators.
+In fact, any project team
+could use a tool for sorting out and discussing all the
+relevant issues.  A common approach is to set up some kind
+of "to-do" list that people can share.
+
+However, to address the overall problem we need much more
+than just a shared to-do list; we need to
+manage a growing body of knowledge and experience to help a
+team collaborate effectively on a project.  The issue-tracking
+tool becomes a nexus for communication: the Grand Central
+Station of the group intelligence.
+
+The primary focus of this design is to help
+developers work together well, not to provide a customer
+service interface to the developers.  This is not to say that
+the design is to be made unsuitable for customers to use.
+Rather, it is assumed that many of the same qualities
+that are good for supporting development (see below)
+are also good for non-developers using the system.
+Additional niceties
+for providing a safe or simplified interface to clients are
+intentionally deferred for later consideration.
+
+A good issue-tracking system should have at least the
+following properties:
+
+**Low barrier to participation**
+  The usefulness of the tool depends entirely on the
+  information people contribute to it.  It must be made
+  as easy as possible to submit new issues and contribute
+  information about existing issues.
+
+**Straightforward navigation**
+  It should be easy for users to extract information they need
+  from the system to direct their decisions and tasks.
+  They should be able to get a decent overview of
+  things as well as finding specific information when
+  they know what they're after.
+
+**Controlled information flow**
+  The users must have control over how much information and
+  what information they get.  A common flaw of some issue-tracking
+  systems is that they inundate users with so much useless
+  e-mail that people avoid the system altogether.
+
+With a nod to the time-honoured computer science tradition
+of "filling in the fourth quadrant", we note that
+there are really four kinds of information flow
+going on here.  The three mentioned qualities
+really address the first three quadrants of this 2-by-2 matrix,
+respectively:
+
+1. User push: a user submits information to the system.
+2. User pull: a user queries for information from the system.
+3. System push: the system sends information out to users.
+4. System pull: the system solicits information from users.
+
+An example of the fourth kind of flow is voting.
+Voting isn't described in this design,
+but it should be noted as a potential enhancement.
+
+
+Guiding Principles
+------------------
+
+**Simplicity**
+  It is a strong requirement
+  that the tool be accessible and understandable.  It should
+  be fairly obvious what different parts of the interface do,
+  and the inner mechanisms should operate in ways that most
+  users can easily predict.
+
+**Efficiency**
+  We aim to optimize for minimum effort to do the most common
+  operations, and best use of resources like screen real estate
+  to maximize the amount of information that we summarize and present.
+
+**Generality**
+  We try to avoid making
+  unnecessary assumptions that would restrict the applicability
+  of the tool.  For example, there is no reason why one might
+  not also want to use this tool to manage a design process,
+  non-software projects, or organizational decisions.
+
+**Persistence** We
+  prefer hiding or reclassifying information to deleting it.
+  This helps support the collection of statistics later.
+  If records are never destroyed, there is little danger
+  in providing access to a larger community, and logging yields
+  accountability, which may encourage better behaviour.
+
+
+Data Model
+==========
+
+Roundup stores a number of *items*, each of
+which can have several properties and an associated discussion.
+The properties can be used to classify or search for items.
+The discussion is a sequence of e-mail messages.
+Each item is identified by a unique number, and has
+an activity log which
+records the time and content of edits made on its properties.
+The log stays fairly small since the design intentionally
+provides only small data types as item properties, and
+encourages anything large to be attached to
+e-mail where it becomes part of the discussion.
+The next section explains how items are organized.
+
+
+The Hyperdatabase
+-----------------
+
+Often when classifying information we are
+asked to select exactly one of a number of categories
+or to fit it into a rigid hierarchy.  Yet things
+only sometimes fall into one category; often,
+a piece of information may be related to several concepts.
+
+For example, forcing each item into a single topic
+category is not just suboptimal but counterproductive:
+seekers of that
+item may expect to find it in a different category
+and conclude that the item is not present in the
+database -- which has them *worse* off
+than if the items were not categorized at all.
+
+Some systems try to alleviate this problem by
+allowing items to appear at multiple locations
+in a tree, as with "aliases" or "symbolic links" in
+a filesystem, for example.  This does help somewhat,
+but we want to be even more flexible
+by allowing the
+organization of items into sets that may freely
+intersect.  Rather than putting each item at exactly
+one place in an overall "grand scheme", a item can
+belong to as many sets as are appropriate.
+
+If we choose to represent the sets themselves as items
+and set membership as a link between items,
+we're now ready to present the definition of a
+hyperdatabase.
+
+A *hyperdatabase* is a collection of *items*
+that may be hyperlinked to
+each other (hence the name "hyperdatabase").
+Each item carries a collection of key-value pairs,
+where some of the values may be links to other items.
+Any item may have an arbitrary number of outgoing and
+incoming links.  Hyperdatabases are able to efficiently
+answer queries such as "what items link to this item?"
+and "what items does this item link to?"
+
+Rationale
+---------
+
+There are several reasons for building our
+own kind of database for Roundup rather than using an existing one.
+
+Requiring the installation of a full-blown third-party
+SQL database system would probably deter many potential
+users from attempting to set up Roundup;
+yet a real relational database would be too
+complicated to implement on our own.
+
+On the other hand, a hyperdatabase can be implemented fairly easily
+using one of the Python DBM modules, so we can
+take the "batteries-included" approach and provide it
+as part of the system.  It's easier to build and understand
+than a true relational database (in accordance with our guiding
+principle of *simplicity*), but provides
+most of the query functionality we want.
+
+A hyperdatabase is well suited for finding the intersection
+of a number of sets in which items belong.  We expect that
+most of the queries people want to do will be of this
+form, rather than complicated SQL queries.  For example, a
+typical request might be
+"show me all critical items related to security".
+The ability to store arbitrary key-value pairs and links
+on items gives it more flexibility than an RDBMS.
+
+Users are not going to be making thousands of queries
+per second, so it makes sense to optimize for simplicity
+and flexibility rather than performance.
+
+.. img: images/hyperdb.png
+
+
+Roundup's Hyperdatabase
+-----------------------
+
+For our application, we store each item as a item in a
+hyperdatabase.  The item's properties are stored
+as key-value pairs on its item.
+Several types of properties are allowed:
+*string*, *number*, *boolean*, *date*, *interval, *link*,
+and *multlink*. Another type, *password*, is a special type
+of string and it's only used internally to Roundup.
+
+The *string* type is for short, free-form strings.
+String properties are not intended to contain large
+amounts of text, and it is recommended that they be presented
+as one-line fields to encourage brevity. A *number* is a special
+type of string that represents a numeric value. A *boolean* is
+further constrained to be a *true* or *false* value.
+
+The *date* type is for calendar dates and times. An *interval*
+is the time between two dates.
+
+The *link* type denotes a single selection from a number of
+options.  A *link* property entails a link from the item
+possessing the property to the item representing the chosen option.
+
+The *multilink* type is for a list of links to any
+number of other items in the in the database.  A *multilink*
+property, for example, can be used to refer to related items
+or topic categories relevant to an item.
+
+For Roundup, all items have four properties that are not customizable:
+
+1. a *date* property named **creation**
+2. a *link* property named **creator**
+3. a *date* property named **activity**
+
+These properties represent the date of the creation of the item, who
+created it, and when the last change was made.
+
+Further, all *issue* items have an additional four properties:
+
+1. a *string* property named **title**
+2. a *multilink* property named **nosy**
+3. a *multilink* property named **messages**
+4. a *multilink* property named **files**
+5. a *multilink* property named **superseder**
+
+The **title** property is a short one-line description of the item.
+The detailed description can go in the first e-mail message of the
+item's messages spool.
+
+The **nosy** property contains a list of
+the people who are interested in an item.  This
+mechanism is explained in the section on `Submission and Discussion`_.
+
+Each message added to the item goes in the **messages** spool - any
+attached files go in the **files** spool.
+
+The **superseder** property is used to 
+support the splitting, joining, or replacing of items.
+When several items need to be
+joined into a single item, all the old items
+link to the new item in their **superseder**
+property.
+When an item needs to be split apart, the item
+references all the new items in its **superseder**
+propety.
+We can easily list all active items just by checking
+for an empty **superseder** property, and trace
+the path of an item's origins by querying the hyperdatabase
+for links.
+
+Users of the system are also represented by items in the
+hyperdatabase, containing properties
+like the user's e-mail address, login name, and password.
+
+The Default Schema
+------------------
+
+It is hoped that the hyperdatabase together with the
+specializations mentioned above for Roundup will be
+applicable in a variety of situations
+(in accordance with our guiding principle of *generality*).
+
+To address the problem at hand, we need
+a specific schema for items applied particularly to software development.
+Again, we are trying to keep the schema simple: too many
+options make it tougher for someone to make a good choice::
+
+    # IssueClass automatically gets these properties:
+    #   title = String()
+    #   messages = Multilink("msg")
+    #   files = Multilink("file")
+    #   nosy = Multilink("user")
+    #   superseder = Multilink("issue")
+    #   (it also gets the Class properties creation, activity and creator)
+    issue = IssueClass(db, "issue", 
+                    assignedto=Link("user"), topic=Multilink("keyword"),
+                    priority=Link("priority"), status=Link("status"))
+
+The **assignedto** property assigns
+responsibility for an item to a person or a list of people.
+The **topic** property places the
+item in an arbitrary number of relevant topic sets (see
+the section on `Browsing and Searching`_).
+
+The **prority** and **status** values are initially:
+
+=========== =====================================
+Priority    Description
+=========== =====================================
+"critical"  panic: work is stopped!
+"urgent"    important, but not deadly
+"bug"       lost work or incorrect results
+"feature"   want missing functionality
+"wish"      avoidable bugs, missing conveniences
+=========== =====================================
+
+============= =====================================
+Status        Description
+============= =====================================
+"unread"      submitted but no action yet
+"deferred"    intentionally set aside
+"chatting"    under review or seeking clarification
+"need-eg"     need a reproducible example of a bug
+"in-progress" understood; development in progress
+"testing"     we think it's done; others, please test
+"done-cbb"    okay for now, but could be better
+"resolved"    fix has been released
+============= =====================================
+
+As previously mentioned, each item gets an activity log.
+Whenever a property on an item is changed, the log
+records the time of the change, the user making the change,
+and the old and new values of the property.  This permits
+the later gathering of statistics (for example, the average time
+from submission to resolution).
+
+We do not specify or enforce a state transition graph,
+since making the system rigid in that fashion is probably more
+trouble than it's worth.
+Experience has shown that there are probably
+two convenient automatic state transitions:
+
+1. from **unread** to **chatting** when e-mail is written about an item
+2. from **testing** to **resolved** when a new release of the software is made
+
+Beyond these, in accordance with our principle of *generality*,
+we allow access to the hyperdatabase
+API so that scripts can automate transitions themselves or
+be triggered by changes in the database.
+
+
+User Interface
+==============
+
+Roundup provides its services through two main interfaces:
+e-mail and the Web.
+This division is chosen to optimize the most common tasks.
+
+E-mail is best suited for
+the submission of new items since most people are most comfortable
+with composing long messages in their own favourite e-mail client.
+E-mail also permits them to mention URLs or attach files relevant
+to their submission.  Indeed, in many cases people are already
+used to making requests by sending e-mail to a mailing list
+of people; they can do exactly the same thing to use Roundup
+without even thinking about it.
+Similarly, people are already
+familiar with holding discussions in e-mail, and plenty of
+valuable usage conventions and software tools already exist for that medium.
+
+The Web, on the other hand, is best suited for summarizing
+and seeking information, because it can present an interactive
+overview of items.  Since the Web has forms, it's also
+the best place to edit items.
+
+
+Submission and Discussion
+-------------------------
+
+The system needs an address for receiving mail
+and an address that forwards mail to all participants.
+Each item has its own list
+of interested parties, known as its *nosy list*.
+Here's how nosy lists work:
+
+1. New items are always submitted by sending an e-mail message
+   to Roundup.  The "Subject:" field becomes the description
+   of the new item.
+   The message is saved in the mail spool of the new item,
+   and copied to the list of all participants
+   so everyone knows that a new item has been added.
+   The new item's nosy list initially contains the submitter.
+
+2. All e-mail messages sent by Roundup have their "Reply-To:"
+   field set to Roundup's address, and have the item's
+   number in the "Subject:" field.  Thus, any replies to the
+   initial announcement and subsequent threads are all received
+   by Roundup.  Roundup notes the item number in the "Subject:"
+   field of each incoming message and appends the message
+   to the appropriate spool.
+
+3. Any incoming e-mail tagged with an item number is copied
+   to all the people on the item's nosy list,
+   and any users found in the "From:", "To:", or "Cc:" fields
+   are automatically added to the nosy list.  Whenever a user
+   edits an item's properties in the Web interface, they are
+   also added to the nosy list.
+
+The effect is like each item having its own little mailing list,
+except that no one ever has to worry about subscribing to
+anything.  Indicating interest in an issue is sufficient, and if you
+want to bring someone new into the conversation, all you need to do
+is "Cc:" a message to them.  It turns out that no one ever has to worry
+about unsubscribing, either: the nosy lists are so specific in scope
+that the conversation tends to die down by itself when the issue is
+resolved or people no longer find it sufficiently important.
+
+Each nosy list is like an asynchronous chat room,
+lasting only a short time (typically five or ten messages)
+and involving a small group of people.  However, that
+group is the *right* group of people:
+only those who express interest in an item in some way
+ever end up on the list, so no one gets spammed with mail they
+don't care about, and no one who *wants*
+to see mail about a particular item needs to be left
+out, for they can easily join in, and just as easily
+look at the mail spool on an item to catch up on any
+messages they might have missed.
+
+We can take this a step further and
+permit users to monitor particular topics or classifications of items
+by allowing other kinds of items to also have their own nosy lists.
+For example, a manager could be on the
+nosy list of the priority value item for "critical", or a
+developer could be on the nosy list of the topic value item for "security".
+The recipients are then determined by the union of the nosy lists on the
+item and all the items it links to.
+
+Using many small, specific mailing lists results
+in much more effective communication than one big list.
+Taking away the effort of subscribing and unsubscribing
+gives these lists the "feel" of being cheap and
+disposable.
+
+The transparent capture of the mail spool attached to each
+issue also yields a nice knowledge repository over time.
+
+
+Editing
+-------
+
+Since Roundup is intended to support arbitrary user-defined
+schema for item properties, the editing interface must be
+automatically generated from the schema.  The configuration for
+Roundup will include a template describing how to lay out the
+properties to present a UI for inspecting and editing items.
+For example::
+
+ <tr>
+  <th class="required">Priority</th>
+  <td tal:content="structure context/priority/menu">priority</td>
+  <th>Status</th>
+  <td tal:content="structure context/status/menu">status</td>
+ </tr>
+
+To display the editing form for an item, Roundup inserts
+an HTML form widget where it encounters an expression like
+``tal:content="structure context/priority/menu"``.
+Each type has its own appropriate editing widget:
+
+- *string* and *number* properties appear as text fields
+- *boolean* properties appear as a yes/no selection
+- *date* and *interval* properties appear as text fields
+- *link* properties appear as selection lists
+- *multilink* properties appear as multiple-selection lists
+    or text fields with pop-up widgets for larger selections.
+
+We foresee the use of custom date fields for things like deadlines,
+so input fields for *date* properties support a
+simple way of specifying relative dates (such as "3w" for
+"three weeks from now").
+
+The **superseder** property is a special case:
+although it is more efficient to store a **superseder**
+property in the superseded item, it makes more sense to provide
+a "supersedes" edit field on the superseding item.  We use
+a special widget on items for this purpose (a text field containing
+a comma-separated list of items).  Links in the **superseder** property
+appear on both the superseding and superseded items to
+facilitate navigating an item's pedigree.
+
+After the editing widgets, the item inspection page shows
+a "note" text box and then a display of the messages in the
+discussion spool.  This field
+lets you enter a note explaining your change when you edit the
+item, and the note is included in the notification message that
+goes out to tell the interested parties on the nosy list of
+your edits.
+
+Browsing and Searching
+----------------------
+
+The ideal we would like to achieve is to make searching as
+much like browsing as possible: the user simply clicks about
+on things that seem interesting, and the information narrows
+down comfortably until the goal is in sight.  This is preferable
+to trying to digest a screen filled with widgets and buttons
+or entering a search expression in some arcane algebraic syntax.
+
+While a one-shot search may be appropriate when you're
+looking for a single item and you know exactly what you want, it's
+not very helpful when you want an overview of
+things ("Gee, there are a lot more high-priority items than
+there were last week!") or trying to do comparisons ("I have
+some time today, so who is busiest and could most use some help?")
+
+The browsing interface presents filtering
+functionality for each of the properties in the schema.  As with
+editing, the interface is generated from a template
+describing how to lay out the properties.
+Each type of property has its own appropriate filtering widget:
+
+- *string* properties appear as text fields supporting
+  case-insensitive substring match
+- *date* properties appear as a text field which accepts a date
+  range with start, end or both
+- *link* properties appear as a group of selectable options
+  (the filter selects the *union* of the sets of items
+  associated with the active options)
+- *multilink* properties appear as a group of selectable options
+  (the filter selects the *intersection* of the sets of items
+  associated with the active options)
+
+For a *multilink* property like **topic**,
+one possibility is to show, as hyperlinks, the keywords whose
+sets have non-empty intersections with the currently displayed set of
+items.  Sorting the keywords by popularity seems
+reasonable.  Clicking on a keyword then narrows both the list of items
+and the list of keywords.  This gives some of the feel of walking
+around a directory tree -- but without the restriction of having
+to select keywords in a particular hierarchical order, and without
+the need to travel all the way to the leaves of the tree before
+any items are visible.
+
+Below the filtering form is a listing of items, with their
+properties displayed in a table.  Rows in the table are 
+generated from a template, as with the editing interface.
+This listing is the central overview of the system, and it
+should aim to maximize the density of
+useful information in accordance with our guiding principle of
+*efficiency*.  Colour may be used to indicate
+the status of each item to help the eye sift through the index quickly.
+
+Roundup sorts items
+in groups by priority, and then within groups by the date
+of last activity.  This reveals at a glance where discussion is
+most active, and provides an easy way for anyone to move an issue
+up in the list.
+
+The page produced by a given set of browsing options constitutes
+an *index*.  The options should all be part of the query
+parameters in the URL so that views may be bookmarked.  An index
+specifies:
+
+- search strings for string properties
+- date ranges for date properties
+- acceptable values for choice properties
+- required values for reference properties
+- a sorting key
+- a grouping key
+- a list of properties for which to display filtering widgets
+
+Our default index is:
+
+- all **status** values except "resolved"
+- show **priority** and **fixer**
+- grouping by **priority** in sections
+- sorting by decreasing **activity** date
+
+The starting URL for Roundup immediately presents the listing of
+items generated by this default index, with no preceding query screen.
+

Added: tracker/vendor/roundup/current/doc/postgresql.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/postgresql.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,40 @@
+==========================
+PostgreSQL/psycopg Backend
+==========================
+
+This are notes about PostqreSQL backend based on the psycopg adapter for
+Roundup issue tracker.
+
+Prerequisites
+=============
+
+To use PostgreSQL as backend for storing roundup data, you should
+additionally install:
+
+1. PostgreSQL 7.x - http://www.postgresql.org/
+
+2. The psycopg python interface to PostgreSQL:
+   http://initd.org/software/initd/psycopg
+
+Some advice on setting up the postgresql backend may be found at:
+
+  http://www.magma.com.ni/wiki/index.cgi?TipsRoundupPostgres
+
+
+Running the PostgreSQL unit tests
+=================================
+
+The user that you're running the tests as will need to be able to access
+the postgresql database on the local machine and create and drop
+databases. Edit the ``test/test_postgresql.py`` database connection info if
+you wish to test against a different database.
+
+The test database will be called "rounduptest".
+
+
+Credit
+======
+
+The postgresql backend was originally submitted by Federico Di Gregorio
+<fog at initd.org>
+

Added: tracker/vendor/roundup/current/doc/roundup-admin.1
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/roundup-admin.1	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,26 @@
+.TH ROUNDUP-ADMIN 1 "24 January 2003"
+.SH NAME
+roundup-admin \- administrate roundup trackers
+.SH SYNOPSIS
+\fBroundup-admin\fP [\fIoptions\fP] \fI<command>\fP \fI<arguments>\fP
+.SH OPTIONS
+.TP
+\fB-i\fP \fIinstance home\fP
+specify the issue tracker "home directory" to administer
+.TP
+\fB-u\fP
+the user[:password] to use for commands
+.TP
+\fB-c\fP
+when outputting lists of data, just comma-separate them
+.SH FURTHER HELP
+ roundup-admin -h
+ roundup-admin help                       -- this help
+ roundup-admin help <command>             -- command-specific help
+ roundup-admin help all                   -- all available help
+.SH AUTHOR
+This manpage was written by Bastian Kleineidam
+<calvin at debian.org> for the Debian distribution of roundup.
+
+The main author of roundup is Richard Jones
+<richard at users.sourceforge.net>.

Added: tracker/vendor/roundup/current/doc/roundup-demo.1
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/roundup-demo.1	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,30 @@
+.TH ROUNDUP-SERVER 1 "27 July 2004"
+.SH NAME
+roundup-demo \- create a roundup "demo" tracker and launch its web interface
+.SH SYNOPSIS
+\fBroundup-demo\fP [\fIbackend\fP [\fBnuke\fP]]
+.SH OPTIONS
+.TP
+\fBnuke\fP
+Create a fresh demo tracker (deleting the existing one if any). If the
+additional \fIbackend\fP argument is specified, the new demo tracker will
+use the backend named (one of "anydbm", "sqlite", "metakit", "mysql" or
+"postgresql"; subject to availability on your system).
+.TH DESCRIPTION
+This command creates a fresh demo tracker for you to experiment with. The
+email features of Roundup will be turned off (so the nosy feature won't
+send email). It does this by removing the \fInosyreaction.py\fP module
+from the demo tracker's \fIdetectors\fP directory.
+
+If you wish, you may modify the demo tracker by editing its configuration
+files and HTML templates. See the \fIcustomisation\fP manual for
+information about how to do that.
+
+Once you've fiddled with the demo tracker, you may use it as a template for
+creating your real, live tracker. Simply run the \fIroundup-admin\fP
+command to install the tracker from inside the demo tracker home directory,
+and it will be listed as an available template for installation. No data
+will be copied over.
+.SH AUTHOR
+This manpage was written by Richard Jones
+<richard at users.sourceforge.net>.

Added: tracker/vendor/roundup/current/doc/roundup-favicon.ico
==============================================================================
Binary file. No diff available.

Added: tracker/vendor/roundup/current/doc/roundup-mailgw.1
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/roundup-mailgw.1	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,59 @@
+.TH ROUNDUP-MAILGW 1 "24 January 2003"
+.SH NAME
+roundup-mailgw \- mail gateway for roundup
+.SH SYNOPSIS
+\fBroundup-mailgw\fP \fI<instance home>\fP [\fImethod\fP]
+.SH OPTIONS
+.TP
+\fB-C\fP \fIhyperdb class\fP
+specify a tracker class - one of msg (the default), issue, file, user - to
+manipulate with -S options
+.TP
+\fB-S\fP \fIproperty=value[;property=value] pairs\fP
+specify the values to set on the class specified by -C using the same
+format as the Subject line property manipulations
+.SH DESCRIPTION
+The roundup mail gateway may be called in one of three ways:
+.IP \(bu
+with an instance home as the only argument,
+.IP \(bu
+with both an instance home and a mail spool file, or
+.IP \(bu
+with both an instance home and a pop server account.
+.PP
+\fBPIPE\fP
+.br
+In the first case, the mail gateway reads a single message from the
+standard input and submits the message to the roundup.mailgw module.
+
+\fBUNIX mailbox\fP
+.br
+In the second case, the gateway reads all messages from the mail spool
+file and submits each in turn to the roundup.mailgw module. The file is
+emptied once all messages have been successfully handled. The file is
+specified as:
+ \fImailbox /path/to/mailbox\fP
+
+\fBPOP\fP
+.br
+In the third case, the gateway reads all messages from the POP server
+specified and submits each in turn to the roundup.mailgw module. The
+server is specified as:
+ \fIpop username:password at server\fP
+.br
+The username and password may be omitted:
+ \fIpop username at server\fP
+ \fIpop server\fP
+.br
+are both valid. The username and/or password will be prompted for if
+not supplied on the command-line.
+
+\fBAPOP\fP
+Same as POP, but using Authenticated POP:
+ \fIapop username:password at server\fP
+.SH AUTHOR
+This manpage was written by Bastian Kleineidam
+<calvin at debian.org> for the Debian distribution of roundup.
+
+The main author of roundup is Richard Jones
+<richard at users.sourceforge.net>.

Added: tracker/vendor/roundup/current/doc/roundup-server.1
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/roundup-server.1	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,43 @@
+.TH ROUNDUP-SERVER 1 "27 July 2004"
+.SH NAME
+roundup-server \- start roundup web server
+.SH SYNOPSIS
+\fBroundup-server\fP [\fIoptions\fP] [\fBname=\fP\fItracker home\fP]*
+.SH OPTIONS
+.TP
+\fB-C\fP \fIfile\fP
+Use options read from the configuration file (see below).
+.TP
+\fB-n\fP \fIhostname\fP
+Sets the host name.
+.TP
+\fB-p\fP \fIport\fP
+Sets the port to listen on.
+.TP
+\fB-d\fP \fIfile\fP
+Daemonize, and write the server's PID to the nominated file.
+.TP
+\fB-l\fP \fIfile\fP
+Sets a filename to log to (instead of stdout). This is required if the -d
+option is used.
+.TP
+\fB-h\fP
+print help
+.TP
+\fBname=\fP\fItracker home\fP
+Sets the tracker home(s) to use. The name is how the tracker is
+identified in the URL (it's the first part of the URL path). The
+tracker home is the directory that was identified when you did
+"roundup-admin init". You may specify any number of these name=home
+pairs on the command-line. For convenience, you may edit the
+TRACKER_HOMES variable in the roundup-server file instead.
+Make sure the name part doesn't include any url-unsafe characters like
+spaces, as these confuse the cookie handling in browsers like IE.
+.SH CONFIGURATION FILE
+See the "admin_guide" in the Roundup "doc" directory.
+.SH AUTHOR
+This manpage was written by Bastian Kleineidam
+<calvin at debian.org> for the Debian distribution of roundup.
+
+The main author of roundup is Richard Jones
+<richard at users.sourceforge.net>.

Added: tracker/vendor/roundup/current/doc/roundup-server.ini.example
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/roundup-server.ini.example	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,16 @@
+; This is a sample configuration file for roundup-server. See the
+; admin_guide for information about its contents.
+[server]
+port = 8080
+;hostname = 
+;user = 
+;group = 
+;log_ip = yes
+;pidfile = 
+;logfile = 
+
+
+; Add one of these per tracker being served
+[tracker_url_component]
+home = /path/to/tracker
+

Added: tracker/vendor/roundup/current/doc/spec.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/spec.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,1546 @@
+<html>
+<head>
+<title>Software Carpentry Track: Roundup</title>
+</head>
+<body bgcolor=white>
+
+<table width="100%">
+<tr>
+
+<td align="left">
+<a href="http://www.software-carpentry.com"><img
+src="images/logo-software-carpentry-standard.png" alt="[Software Carpentry logo]" border="0"></a>
+</td>
+
+<td align="right">
+<table>
+<tr><td>
+<a href="http://www.acl.lanl.gov"><img src="images//logo-acl-medium.png" alt="[ACL Logo]" border="0"></a>
+</td></tr>
+<tr><td><hr></td></tr>
+<tr><td>
+<a href="http://www.codesourcery.com"><img
+src="images/logo-codesourcery-medium.png" alt="[CodeSourcery Logo]" border="0"></a>
+</td></tr>
+</table>
+</td>
+
+</tr>
+</table>
+
+<hr><p>
+
+<h1 align=center>Roundup</h1>
+<h3 align=center>An Issue-Tracking System for Knowledge Workers</h3>
+<h4 align=center><a href="http://www.lfw.org/ping/">Ka-Ping Yee</a><br>
+<a href="mailto:ping at lfw.org">ping at lfw.org</a></h4>
+<h3 align=center>Implementation Guide</h3>
+
+<h2>Contents</h2>
+
+<ol>
+<li>Introduction
+<li>The Layer Cake
+<li>Hyperdatabase
+    <ol>
+    <li>Dates and Date Arithmetic
+    <li>Items and Classes
+    <li>Identifiers and Designators
+    <li>Property Names and Types
+    <li>Interface Specification
+    <li>Application Example
+    </ol>
+<li>Roundup Database
+    <ol>
+    <li>Reserved Classes
+        <ol>
+        <li>Users
+        <li>Messages
+        <li>Files
+        </ol>
+    <li>Item Classes
+    <li>Interface Specification
+    <li>Default Schema
+    </ol>
+<li>Detector Interface
+    <ol>
+    <li>Interface Specification
+    <li>Detector Example
+    </ol>
+<li>Command Interface
+    <ol>
+    <li>Interface Specification
+    <li>Usage Example
+    </ol>
+<li>E-mail User Interface
+    <ol>
+    <li>Message Processing
+    <li>Nosy Lists
+    <li>Setting Properties
+    <li>Workflow Example
+    </ol>
+<li>Web User Interface
+    <ol>
+    <li>Views and View Specifiers
+    <li>Displaying Properties
+    <li>Index Views
+        <ol>
+        <li>Index View Specifiers
+        <li>Filter Section
+        <li>Index Section
+        <li>Sorting
+        </ol>
+    <li>Item Views
+        <ol>
+        <li>Item View Specifiers
+        <li>Editor Section
+        <li>Spool Section
+        </ol>
+    </ol>
+<li>Deployment Scenarios
+<li>Acknowledgements
+</ol>
+
+<p><hr>
+<h2>1. Introduction</h2>
+
+<p>This document presents a description of the components
+of the Roundup system and specifies their interfaces and
+behaviour in sufficient detail to guide an implementation.
+For the philosophy and rationale behind the Roundup design,
+see the first-round Software Carpentry submission for Roundup.
+This document fleshes out that design as well as specifying
+interfaces so that the components can be developed separately.
+
+<p><hr>
+<h2>2. The Layer Cake</h2>
+
+<p>Lots of software design documents come with a picture of
+a cake.  Everybody seems to like them.  I also like cakes
+(i think they are tasty).  So i, too, shall include
+a picture of a cake here.
+
+<p align=center><table cellspacing=0 cellpadding=10 border=0 align=center>
+<tr>
+<td bgcolor="#e8e8e8" align=center>
+<p><font face="helvetica, arial"><small>
+E-mail Client
+</small></font>
+</td>
+<td bgcolor="#e0e0e0" align="center">
+<p><font face="helvetica, arial"><small>
+Web Browser
+</small></font>
+</td>
+<td bgcolor="#e8e8e8" align=center>
+<p><font face="helvetica, arial"><small>
+Detector Scripts
+</small></font>
+</td>
+<td bgcolor="#e0e0e0" align="center">
+<p><font face="helvetica, arial"><small>
+Shell
+</small></font>
+</td>
+<tr>
+<td bgcolor="#d0d0f0" align=center>
+<p><font face="helvetica, arial"><small>
+E-mail User Interface
+</small></font>
+</td>
+<td bgcolor="#f0d0d0" align=center>
+<p><font face="helvetica, arial"><small>
+Web User Interface
+</small></font>
+</td>
+<td bgcolor="#d0f0d0" align=center>
+<p><font face="helvetica, arial"><small>
+Detector Interface
+</small></font>
+</td>
+<td bgcolor="#f0d0f0" align=center>
+<p><font face="helvetica, arial"><small>
+Command Interface
+</small></font>
+</td>
+<tr>
+<td bgcolor="#f0f0d0" colspan=4 align=center>
+<p><font face="helvetica, arial"><small>
+Roundup Database Layer
+</small></font>
+</td>
+<tr>
+<td bgcolor="#d0f0f0" colspan=4 align=center>
+<p><font face="helvetica, arial"><small>
+Hyperdatabase Layer
+</small></font>
+</td>
+<tr>
+<td bgcolor="#e8e8e8" colspan=4 align=center>
+<p><font face="helvetica, arial"><small>
+Storage Layer
+</small></font>
+</td>
+</table>
+
+<p>The colourful parts of the cake are part of our system;
+the faint grey parts of the cake are external components.
+
+<p>I will now proceed to forgo all table manners and
+eat from the bottom of the cake to the top.  You may want
+to stand back a bit so you don't get covered in crumbs.
+
+<p><hr>
+<h2>3. Hyperdatabase</h2>
+
+<p>The lowest-level component to be implemented is the hyperdatabase.
+The hyperdatabase is intended to be
+a flexible data store that can hold configurable data in
+records which we call <em>items</em>.
+
+<p>The hyperdatabase is implemented on top of the storage layer,
+an external module for storing its data.  The storage layer could
+be a third-party RDBMS; for a "batteries-included" distribution,
+implementing the hyperdatabase on the standard <tt>bsddb</tt>
+module is suggested.
+
+<h3>3.1. Dates and Date Arithmetic</h3>
+
+<p>Before we get into the hyperdatabase itself, we need a
+way of handling dates.  The hyperdatabase module provides
+Timestamp objects for
+representing date-and-time stamps and Interval objects for
+representing date-and-time intervals.
+
+<p>As strings, date-and-time stamps are specified with
+the date in international standard format
+(<em>yyyy</em>-<em>mm</em>-<em>dd</em>)
+joined to the time (<em>hh</em>:<em>mm</em>:<em>ss</em>)
+by a period (".").  Dates in
+this form can be easily compared and are fairly readable
+when printed.  An example of a valid stamp is
+"<strong>2000-06-24.13:03:59</strong>".
+We'll call this the "full date format".  When Timestamp objects are
+printed as strings, they appear in the full date format with
+the time always given in GMT.  The full date format is always
+exactly 19 characters long.
+
+<p>For user input, some partial forms are also permitted:
+the whole time or just the seconds may be omitted; and the whole date
+may be omitted or just the year may be omitted.  If the time is given,
+the time is interpreted in the user's local time zone.
+The <tt>Date</tt> constructor takes care of these conversions.
+In the following examples, suppose that <em>yyyy</em> is the current year,
+<em>mm</em> is the current month, and <em>dd</em> is the current
+day of the month; and suppose that the user is on Eastern Standard Time.
+
+<ul>
+<li>"<strong>2000-04-17</strong>" means &lt;Date 2000-04-17.00:00:00&gt;
+<li>"<strong>01-25</strong>" means &lt;Date <em>yyyy</em>-01-25.00:00:00&gt;
+<li>"<strong>2000-04-17.03:45</strong>" means &lt;Date 2000-04-17.08:45:00&gt;
+<li>"<strong>08-13.22:13</strong>" means &lt;Date <em>yyyy</em>-08-14.03:13:00&gt;
+<li>"<strong>11-07.09:32:43</strong>" means &lt;Date <em>yyyy</em>-11-07.14:32:43&gt;
+<li>"<strong>14:25</strong>" means
+&lt;Date <em>yyyy</em>-<em>mm</em>-<em>dd</em>.19:25:00&gt;
+<li>"<strong>8:47:11</strong>" means
+&lt;Date <em>yyyy</em>-<em>mm</em>-<em>dd</em>.13:47:11&gt;
+<li>the special date "<strong>.</strong>" means "right now"
+</ul>
+
+<p>Date intervals are specified using the suffixes
+"y", "m", and "d".  The suffix "w" (for "week") means 7 days.
+Time intervals are specified in hh:mm:ss format (the seconds
+may be omitted, but the hours and minutes may not).
+
+<ul>
+<li>"<strong>3y</strong>" means three years
+<li>"<strong>2y 1m</strong>" means two years and one month
+<li>"<strong>1m 25d</strong>" means one month and 25 days
+<li>"<strong>2w 3d</strong>" means two weeks and three days
+<li>"<strong>1d 2:50</strong>" means one day, two hours, and 50 minutes
+<li>"<strong>14:00</strong>" means 14 hours
+<li>"<strong>0:04:33</strong>" means four minutes and 33 seconds
+</ul>
+
+<p>The Date class should understand simple date expressions of the form 
+<em>stamp</em> + <em>interval</em> and <em>stamp</em> - <em>interval</em>.
+When adding or subtracting intervals involving months or years, the
+components are handled separately.  For example, when evaluating
+"<strong>2000-06-25 + 1m 10d</strong>", we first add one month to
+get <strong>2000-07-25</strong>, then add 10 days to get
+<strong>2000-08-04</strong> (rather than trying to decide whether
+<strong>1m 10d</strong> means 38 or 40 or 41 days).
+
+<p>Here is an outline of the Date and Interval classes.
+
+<blockquote>
+<pre><small>class <strong>Date</strong>:
+    def <strong>__init__</strong>(self, spec, offset):
+        """Construct a date given a specification and a time zone offset.
+
+        'spec' is a full date or a partial form, with an optional
+        added or subtracted interval.  'offset' is the local time
+        zone offset from GMT in hours.
+        """
+
+    def <strong>__add__</strong>(self, interval):
+        """Add an interval to this date to produce another date."""
+
+    def <strong>__sub__</strong>(self, interval):
+        """Subtract an interval from this date to produce another date."""
+
+    def <strong>__cmp__</strong>(self, other):
+        """Compare this date to another date."""
+
+    def <strong>__str__</strong>(self):
+        """Return this date as a string in the yyyy-mm-dd.hh:mm:ss format."""
+
+    def <strong>local</strong>(self, offset):
+        """Return this date as yyyy-mm-dd.hh:mm:ss in a local time zone."""
+
+class <strong>Interval</strong>:
+    def <strong>__init__</strong>(self, spec):
+        """Construct an interval given a specification."""
+
+    def <strong>__cmp__</strong>(self, other):
+        """Compare this interval to another interval."""
+        
+    def <strong>__str__</strong>(self):
+        """Return this interval as a string."""
+</small></pre>
+</blockquote>
+
+<p>Here are some examples of how these classes would behave in practice.
+For the following examples, assume that we are on Eastern Standard
+Time and the current local time is 19:34:02 on 25 June 2000.
+
+<blockquote><pre><small
+>&gt;&gt;&gt; <span class="input">Date(".")</span>
+<span class="output">&lt;Date 2000-06-26.00:34:02&gt;</span>
+&gt;&gt;&gt; <span class="input">_.local(-5)</span>
+<span class="output">"2000-06-25.19:34:02"</span>
+&gt;&gt;&gt; <span class="input">Date(". + 2d")</span>
+<span class="output">&lt;Date 2000-06-28.00:34:02&gt;</span>
+&gt;&gt;&gt; <span class="input">Date("1997-04-17", -5)</span>
+<span class="output">&lt;Date 1997-04-17.00:00:00&gt;</span>
+&gt;&gt;&gt; <span class="input">Date("01-25", -5)</span>
+<span class="output">&lt;Date 2000-01-25.00:00:00&gt;</span>
+&gt;&gt;&gt; <span class="input">Date("08-13.22:13", -5)</span>
+<span class="output">&lt;Date 2000-08-14.03:13:00&gt;</span>
+&gt;&gt;&gt; <span class="input">Date("14:25", -5)</span>
+<span class="output">&lt;Date 2000-06-25.19:25:00&gt;</span>
+&gt;&gt;&gt; <span class="input">Interval("  3w  1  d  2:00")</span>
+<span class="output">&lt;Interval 22d 2:00&gt;</span>
+&gt;&gt;&gt; <span class="input">Date(". + 2d") - Interval("3w")</span>
+<span class="output">&lt;Date 2000-06-07.00:34:02&gt;</span
+></small></pre></blockquote>
+
+<h3>3.2. Items and Classes</h3>
+
+<p>Items contain data in <em>properties</em>.  To Python, these
+properties are presented as the key-value pairs of a dictionary.
+Each item belongs to a <em>class</em> which defines the names
+and types of its properties.  The database permits the creation
+and modification of classes as well as items.
+
+<h3>3.3. Identifiers and Designators</h3>
+
+<p>Each item has a numeric identifier which is unique among
+items in its class.  The items are numbered sequentially
+within each class in order of creation, starting from 1.
+The <em>designator</em>
+for an item is a way to identify an item in the database, and
+consists of the name of the item's class concatenated with
+the item's numeric identifier.
+
+<p>For example, if "spam" and "eggs" are classes, the first
+item created in class "spam" has id 1 and designator "spam1".
+The first item created in class "eggs" also has id 1 but has
+the distinct designator "eggs1".  Item designators are
+conventionally enclosed in square brackets when mentioned
+in plain text.  This permits a casual mention of, say,
+"[patch37]" in an e-mail message to be turned into an active
+hyperlink.
+
+<h3>3.4. Property Names and Types</h3>
+
+<p>Property names must begin with a letter.
+
+<p>A property may be one of five <em>basic types</em>:
+
+<ul>
+<li><em>String</em> properties are for storing arbitrary-length
+strings.
+
+<li><em>Date</em> properties store date-and-time stamps.
+Their values are Timestamp objects.
+
+<li>A <em>Link</em> property refers to a single other item
+selected from a specified class.  The class is part of the property;
+the value is an integer, the id of the chosen item.
+
+<li>A <em>Multilink</em> property refers to possibly many items
+in a specified class.  The value is a list of integers.
+</ul>
+
+<p><tt>None</tt> is also a permitted value for any of these property
+types.  An attempt to store <tt>None</tt> into a String property
+stores the empty string; an attempt to store <tt>None</tt>
+into a Multilink property stores an empty list.
+
+<h3>3.5. Interface Specification</h3>
+
+<p>The hyperdb module provides property objects to designate
+the different kinds of properties.  These objects are used when
+specifying what properties belong in classes.
+
+<blockquote><pre><small
+>class <strong>String</strong>:
+    def <strong>__init__</strong>(self):
+        """An object designating a String property."""
+
+class <strong>Date</strong>:
+    def <strong>__init__</strong>(self):
+        """An object designating a Date property."""
+
+class <strong>Link</strong>:
+    def <strong>__init__</strong>(self, classname):
+        """An object designating a Link property that links to
+        items in a specified class."""
+
+class <strong>Multilink</strong>:
+    def <strong>__init__</strong>(self, classname):
+        """An object designating a Multilink property that links
+        to items in a specified class."""
+</small></pre></blockquote>
+
+<p>Here is the interface provided by the hyperdatabase.
+
+<blockquote><pre><small
+>class <strong>Database</strong>:
+    """A database for storing records containing flexible data types."""
+
+    def <strong>__init__</strong>(self, storagelocator, journaltag):
+        """Open a hyperdatabase given a specifier to some storage.
+
+        The meaning of 'storagelocator' depends on the particular
+        implementation of the hyperdatabase.  It could be a file name,
+        a directory path, a socket descriptor for a connection to a
+        database over the network, etc.
+
+        The 'journaltag' is a token that will be attached to the journal
+        entries for any edits done on the database.  If 'journaltag' is
+        None, the database is opened in read-only mode: the Class.create(),
+        Class.set(), and Class.retire() methods are disabled.
+        """
+
+    def <strong>__getattr__</strong>(self, classname):
+        """A convenient way of calling self.getclass(classname)."""
+
+    def <strong>getclasses</strong>(self):
+        """Return a list of the names of all existing classes."""
+
+    def <strong>getclass</strong>(self, classname):
+        """Get the Class object representing a particular class.
+
+        If 'classname' is not a valid class name, a KeyError is raised.
+        """
+
+class <strong>Class</strong>:
+    """The handle to a particular class of items in a hyperdatabase."""
+
+    def <strong>__init__</strong>(self, db, classname, **properties):
+        """Create a new class with a given name and property specification.
+
+        'classname' must not collide with the name of an existing class,
+        or a ValueError is raised.  The keyword arguments in 'properties'
+        must map names to property objects, or a TypeError is raised.
+        """
+
+    # Editing items:
+
+    def <strong>create</strong>(self, **propvalues):
+        """Create a new item of this class and return its id.
+
+        The keyword arguments in 'propvalues' map property names to values.
+        The values of arguments must be acceptable for the types of their
+        corresponding properties or a TypeError is raised.  If this class
+        has a key property, it must be present and its value must not
+        collide with other key strings or a ValueError is raised.  Any other
+        properties on this class that are missing from the 'propvalues'
+        dictionary are set to None.  If an id in a link or multilink
+        property does not refer to a valid item, an IndexError is raised.
+        """
+
+    def <strong>get</strong>(self, itemid, propname):
+        """Get the value of a property on an existing item of this class.
+
+        'itemid' must be the id of an existing item of this class or an
+        IndexError is raised.  'propname' must be the name of a property
+        of this class or a KeyError is raised.
+        """
+
+    def <strong>set</strong>(self, itemid, **propvalues):
+        """Modify a property on an existing item of this class.
+        
+        'itemid' must be the id of an existing item of this class or an
+        IndexError is raised.  Each key in 'propvalues' must be the name
+        of a property of this class or a KeyError is raised.  All values
+        in 'propvalues' must be acceptable types for their corresponding
+        properties or a TypeError is raised.  If the value of the key
+        property is set, it must not collide with other key strings or a
+        ValueError is raised.  If the value of a Link or Multilink
+        property contains an invalid item id, a ValueError is raised.
+        """
+
+    def <strong>retire</strong>(self, itemid):
+        """Retire an item.
+        
+        The properties on the item remain available from the get() method,
+        and the item's id is never reused.  Retired items are not returned
+        by the find(), list(), or lookup() methods, and other items may
+        reuse the values of their key properties.
+        """
+
+    def <strong>history</strong>(self, itemid):
+        """Retrieve the journal of edits on a particular item.
+
+        'itemid' must be the id of an existing item of this class or an
+        IndexError is raised.
+
+        The returned list contains tuples of the form
+
+            (date, tag, action, params)
+
+        'date' is a Timestamp object specifying the time of the change and
+        'tag' is the journaltag specified when the database was opened.
+        'action' may be:
+
+            'create' or 'set' -- 'params' is a dictionary of property values
+            'link' or 'unlink' -- 'params' is (classname, itemid, propname)
+            'retire' -- 'params' is None
+        """
+
+    # Locating items:
+
+    def <strong>setkey</strong>(self, propname):
+        """Select a String property of this class to be the key property.
+
+        'propname' must be the name of a String property of this class or
+        None, or a TypeError is raised.  The values of the key property on
+        all existing items must be unique or a ValueError is raised.
+        """
+
+    def <strong>getkey</strong>(self):
+        """Return the name of the key property for this class or None."""
+
+    def <strong>lookup</strong>(self, keyvalue):
+        """Locate a particular item by its key property and return its id.
+
+        If this class has no key property, a TypeError is raised.  If the
+        'keyvalue' matches one of the values for the key property among
+        the items in this class, the matching item's id is returned;
+        otherwise a KeyError is raised.
+        """
+
+    def <strong>find</strong>(self, propname, itemid):
+        """Get the ids of items in this class which link to a given item.
+        
+        'propname' must be the name of a property in this class, or a
+        KeyError is raised.  That property must be a Link or Multilink
+        property, or a TypeError is raised.  'itemid' must be the id of
+        an existing item in the class linked to by the given property,
+        or an IndexError is raised.
+        """
+
+    def <strong>list</strong>(self):
+        """Return a list of the ids of the active items in this class."""
+
+    def <strong>count</strong>(self):
+        """Get the number of items in this class.
+
+        If the returned integer is 'numitems', the ids of all the items
+        in this class run from 1 to numitems, and numitems+1 will be the
+        id of the next item to be created in this class.
+        """
+
+    # Manipulating properties:
+
+    def <strong>getprops</strong>(self):
+        """Return a dictionary mapping property names to property objects."""
+
+    def <strong>addprop</strong>(self, **properties):
+        """Add properties to this class.
+
+        The keyword arguments in 'properties' must map names to property
+        objects, or a TypeError is raised.  None of the keys in 'properties'
+        may collide with the names of existing properties, or a ValueError
+        is raised before any properties have been added.
+        """</small></pre></blockquote>
+
+<h3>3.6. Application Example</h3>
+
+<p>Here is an example of how the hyperdatabase module would work in practice.
+
+<blockquote><pre><small
+>&gt;&gt;&gt; <span class="input">import hyperdb</span>
+&gt;&gt;&gt; <span class="input">db = hyperdb.Database("foo.db", "ping")</span>
+&gt;&gt;&gt; <span class="input">db</span>
+<span class="output">&lt;hyperdb.Database "foo.db" opened by "ping"&gt;</span>
+&gt;&gt;&gt; <span class="input">hyperdb.Class(db, "status", name=hyperdb.String())</span>
+<span class="output">&lt;hyperdb.Class "status"&gt;</span>
+&gt;&gt;&gt; <span class="input">_.setkey("name")</span>
+&gt;&gt;&gt; <span class="input">db.status.create(name="unread")</span>
+<span class="output">1</span>
+&gt;&gt;&gt; <span class="input">db.status.create(name="in-progress")</span>
+<span class="output">2</span>
+&gt;&gt;&gt; <span class="input">db.status.create(name="testing")</span>
+<span class="output">3</span>
+&gt;&gt;&gt; <span class="input">db.status.create(name="resolved")</span>
+<span class="output">4</span>
+&gt;&gt;&gt; <span class="input">db.status.count()</span>
+<span class="output">4</span>
+&gt;&gt;&gt; <span class="input">db.status.list()</span>
+<span class="output">[1, 2, 3, 4]</span>
+&gt;&gt;&gt; <span class="input">db.status.lookup("in-progress")</span>
+<span class="output">2</span>
+&gt;&gt;&gt; <span class="input">db.status.retire(3)</span>
+&gt;&gt;&gt; <span class="input">db.status.list()</span>
+<span class="output">[1, 2, 4]</span>
+&gt;&gt;&gt; <span class="input">hyperdb.Class(db, "issue", title=hyperdb.String(), status=hyperdb.Link("status"))</span>
+<span class="output">&lt;hyperdb.Class "issue"&gt;</span>
+&gt;&gt;&gt; <span class="input">db.issue.create(title="spam", status=1)</span>
+<span class="output">1</span>
+&gt;&gt;&gt; <span class="input">db.issue.create(title="eggs", status=2)</span>
+<span class="output">2</span>
+&gt;&gt;&gt; <span class="input">db.issue.create(title="ham", status=4)</span>
+<span class="output">3</span>
+&gt;&gt;&gt; <span class="input">db.issue.create(title="arguments", status=2)</span>
+<span class="output">4</span>
+&gt;&gt;&gt; <span class="input">db.issue.create(title="abuse", status=1)</span>
+<span class="output">5</span>
+&gt;&gt;&gt; <span class="input">hyperdb.Class(db, "user", username=hyperdb.Key(), password=hyperdb.String())</span>
+<span class="output">&lt;hyperdb.Class "user"&gt;</span>
+&gt;&gt;&gt; <span class="input">db.issue.addprop(fixer=hyperdb.Link("user"))</span>
+&gt;&gt;&gt; <span class="input">db.issue.getprops()</span>
+<span class="output"
+>{"title": &lt;hyperdb.String&gt;, "status": &lt;hyperdb.Link to "status"&gt;,
+ "user": &lt;hyperdb.Link to "user"&gt;}</span>
+&gt;&gt;&gt; <span class="input">db.issue.set(5, status=2)</span>
+&gt;&gt;&gt; <span class="input">db.issue.get(5, "status")</span>
+<span class="output">2</span>
+&gt;&gt;&gt; <span class="input">db.status.get(2, "name")</span>
+<span class="output">"in-progress"</span>
+&gt;&gt;&gt; <span class="input">db.issue.get(5, "title")</span>
+<span class="output">"abuse"</span>
+&gt;&gt;&gt; <span class="input">db.issue.find("status", db.status.lookup("in-progress"))</span>
+<span class="output">[2, 4, 5]</span>
+&gt;&gt;&gt; <span class="input">db.issue.history(5)</span>
+<span class="output"
+>[(&lt;Date 2000-06-28.19:09:43&gt;, "ping", "create", {"title": "abuse", "status": 1}),
+ (&lt;Date 2000-06-28.19:11:04&gt;, "ping", "set", {"status": 2})]</span>
+&gt;&gt;&gt; <span class="input">db.status.history(1)</span>
+<span class="output"
+>[(&lt;Date 2000-06-28.19:09:43&gt;, "ping", "link", ("issue", 5, "status")),
+ (&lt;Date 2000-06-28.19:11:04&gt;, "ping", "unlink", ("issue", 5, "status"))]</span>
+&gt;&gt;&gt; <span class="input">db.status.history(2)</span>
+<span class="output"
+>[(&lt;Date 2000-06-28.19:11:04&gt;, "ping", "link", ("issue", 5, "status"))]</span>
+</small></pre></blockquote>
+
+<p>For the purposes of journalling, when a Multilink property is
+set to a new list of items, the hyperdatabase compares the old
+list to the new list.
+The journal records "unlink" events for all the items that appear
+in the old list but not the new list,
+and "link" events for
+all the items that appear in the new list but not in the old list.
+
+<p><hr>
+<h2>4. Roundup Database</h2>
+
+<p>The Roundup database layer is implemented on top of the
+hyperdatabase and mediates calls to the database.
+Some of the classes in the Roundup database are considered
+<em>item classes</em>.
+The Roundup database layer adds detectors and user items,
+and on items it provides mail spools, nosy lists, and superseders.
+
+<h3>4.1. Reserved Classes</h3>
+
+<p>Internal to this layer we reserve three special classes
+of items that are not items.
+
+<h4>4.1.1. Users</h4>
+
+<p>Users are stored in the hyperdatabase as items of
+class "user".  The "user" class has the definition:
+
+<blockquote><pre><small
+>hyperdb.Class(db, "user", username=hyperdb.String(),
+                          password=hyperdb.String(),
+                          address=hyperdb.String())
+db.user.setkey("username")</small></pre></blockquote>
+
+<h4>4.1.2. Messages</h4>
+
+<p>E-mail messages are represented by hyperdatabase items of class "msg".
+The actual text content of the messages is stored in separate files.
+(There's no advantage to be gained by stuffing them into the
+hyperdatabase, and if messages are stored in ordinary text files,
+they can be grepped from the command line.)  The text of a message is
+saved in a file named after the message item designator (e.g. "msg23")
+for the sake of the command interface (see below).  Attachments are
+stored separately and associated with "file" items.
+The "msg" class has the definition:
+
+<blockquote><pre><small
+>hyperdb.Class(db, "msg", author=hyperdb.Link("user"),
+                         recipients=hyperdb.Multilink("user"),
+                         date=hyperdb.Date(),
+                         summary=hyperdb.String(),
+                         files=hyperdb.Multilink("file"))</small
+></pre></blockquote>
+
+<p>The "author" property indicates the author of the message
+(a "user" item must exist in the hyperdatabase for any messages
+that are stored in the system).
+The "summary" property contains a summary of the message for display
+in a message index.
+
+<h4>4.1.3. Files</h4>
+
+<p>Submitted files are represented by hyperdatabase
+items of class "file".  Like e-mail messages, the file content
+is stored in files outside the database,
+named after the file item designator (e.g. "file17").
+The "file" class has the definition:
+
+<blockquote><pre><small
+>hyperdb.Class(db, "file", user=hyperdb.Link("user"),
+                          name=hyperdb.String(),
+                          type=hyperdb.String())</small></pre></blockquote>
+
+<p>The "user" property indicates the user who submitted the
+file, the "name" property holds the original name of the file,
+and the "type" property holds the MIME type of the file as received.
+
+<h3>4.2. Item Classes</h3>
+
+<p>All items have the following standard properties:
+
+<blockquote><pre><small
+>title=hyperdb.String()
+messages=hyperdb.Multilink("msg")
+files=hyperdb.Multilink("file")
+nosy=hyperdb.Multilink("user")
+superseder=hyperdb.Multilink("item")</small></pre></blockquote>
+
+<p>Also, two Date properties named "creation" and "activity" are
+fabricated by the Roundup database layer.  By "fabricated" we
+mean that no such properties are actually stored in the
+hyperdatabase, but when properties on items are requested, the
+"creation" and "activity" properties are made available.
+The value of the "creation" property is the date when an item was
+created, and the value of the "activity" property is the
+date when any property on the item was last edited (equivalently,
+these are the dates on the first and last records in the item's journal).
+
+<h3>4.3. Interface Specification</h3>
+
+<p>The interface to a Roundup database delegates most method
+calls to the hyperdatabase, except for the following
+changes and additional methods.
+
+<blockquote><pre><small
+>class <strong>Database</strong>:
+    # Overridden methods:
+
+    def <strong>__init__</strong>(self, storagelocator, journaltag):
+        """When the Roundup database is opened by a particular user,
+        the 'journaltag' is the id of the user's "user" item."""
+
+    def <strong>getclass</strong>(self, classname):
+        """This method now returns an instance of either Class or
+        ItemClass depending on whether an item class is specified."""
+
+    # New methods:
+
+    def <strong>getuid</strong>(self):
+        """Return the id of the "user" item associated with the user
+        that owns this connection to the hyperdatabase."""
+
+class <strong>Class</strong>:
+    # Overridden methods:
+
+    def <strong>create</strong>(self, **propvalues):
+    def <strong>set</strong>(self, **propvalues):
+    def <strong>retire</strong>(self, itemid):
+        """These operations trigger detectors and can be vetoed.  Attempts
+        to modify the "creation" or "activity" properties cause a KeyError.
+        """
+
+    # New methods:
+
+    def <strong>audit</strong>(self, event, detector):
+    def <strong>react</strong>(self, event, detector):
+        """Register a detector (see below for more details)."""
+
+class <strong>ItemClass</strong>(Class):
+    # Overridden methods:
+
+    def <strong>__init__</strong>(self, db, classname, **properties):
+        """The newly-created class automatically includes the "messages",
+        "files", "nosy", and "superseder" properties.  If the 'properties'
+        dictionary attempts to specify any of these properties or a
+        "creation" or "activity" property, a ValueError is raised."""
+
+    def <strong>get</strong>(self, itemid, propname):
+    def <strong>getprops</strong>(self):
+        """In addition to the actual properties on the item, these
+        methods provide the "creation" and "activity" properties."""
+
+    # New methods:
+
+    def <strong>addmessage</strong>(self, itemid, summary, text):
+        """Add a message to an item's mail spool.
+
+        A new "msg" item is constructed using the current date, the
+        user that owns the database connection as the author, and
+        the specified summary text.  The "files" and "recipients"
+        fields are left empty.  The given text is saved as the body
+        of the message and the item is appended to the "messages"
+        field of the specified item.
+        """
+
+    def <strong>nosymessage</strong>(self, itemid, msgid):
+        """Send a message to the members of an item's nosy list.
+
+        The message is sent only to users on the nosy list who are not
+        already on the "recipients" list for the message.  These users
+        are then added to the message's "recipients" list.
+        """
+</small></pre></blockquote>
+
+<h3>4.4. Default Schema</h3>
+
+<p>The default schema included with Roundup turns it into a
+typical software bug tracker.  The database is set up like this:
+
+<blockquote><pre><small
+>pri = Class(db, "priority", name=hyperdb.String(), order=hyperdb.String())
+pri.setkey("name")
+pri.create(name="critical", order="1")
+pri.create(name="urgent", order="2")
+pri.create(name="bug", order="3")
+pri.create(name="feature", order="4")
+pri.create(name="wish", order="5")
+
+stat = Class(db, "status", name=hyperdb.String(), order=hyperdb.String())
+stat.setkey("name")
+stat.create(name="unread", order="1")
+stat.create(name="deferred", order="2")
+stat.create(name="chatting", order="3")
+stat.create(name="need-eg", order="4")
+stat.create(name="in-progress", order="5")
+stat.create(name="testing", order="6")
+stat.create(name="done-cbb", order="7")
+stat.create(name="resolved", order="8")
+
+Class(db, "keyword", name=hyperdb.String())
+
+Class(db, "issue", fixer=hyperdb.Multilink("user"),
+                   topic=hyperdb.Multilink("keyword"),
+                   priority=hyperdb.Link("priority"),
+                   status=hyperdb.Link("status"))
+</small></pre></blockquote>
+
+<p>(The "order" property hasn't been explained yet.  It
+gets used by the Web user interface for sorting.)
+
+<p>The above isn't as pretty-looking as the schema specification
+in the first-stage submission, but it could be made just as easy
+with the addition of a convenience function like <tt>Choice</tt>
+for setting up the "priority" and "status" classes:
+
+<blockquote><pre><small
+>def Choice(name, *options):
+    cl = Class(db, name, name=hyperdb.String(), order=hyperdb.String())
+    for i in range(len(options)):
+        cl.create(name=option[i], order=i)
+    return hyperdb.Link(name)
+</small></pre></blockquote>
+
+<p><hr>
+<h2>5. Detector Interface</h2>
+
+<p>Detectors are Python functions that are triggered on certain
+kinds of events.  The definitions of the
+functions live in Python modules placed in a directory set aside
+for this purpose.  Importing the Roundup database module also
+imports all the modules in this directory, and the <tt>init()</tt>
+function of each module is called when a database is opened to
+provide it a chance to register its detectors.
+
+<p>There are two kinds of detectors:
+
+<ul>
+<li>an <em>auditor</em> is triggered just before modifying an item
+<li>a <em>reactor</em> is triggered just after an item has been modified
+</ul>
+
+<p>When the Roundup database is about to perform a
+<tt>create()</tt>, <tt>set()</tt>, or <tt>retire()</tt>
+operation, it first calls any auditors that
+have been registered for that operation on that class.
+Any auditor may raise a <tt>Reject</tt> exception
+to abort the operation.
+
+<p>If none of the auditors raises an exception, the database
+proceeds to carry out the operation.  After it's done, it
+then calls all of the reactors that have been registered
+for the operation.
+
+<h3>5.1. Interface Specification</h3>
+
+<p>The <tt>audit()</tt> and <tt>react()</tt> methods
+register detectors on a given class of items.
+
+<blockquote><pre><small
+>class Class:
+    def <strong>audit</strong>(self, event, detector):
+        """Register an auditor on this class.
+
+        'event' should be one of "create", "set", or "retire".
+        'detector' should be a function accepting four arguments.
+        """
+
+    def <strong>react</strong>(self, event, detector):
+        """Register a reactor on this class.
+
+        'event' should be one of "create", "set", or "retire".
+        'detector' should be a function accepting four arguments.
+        """
+</small></pre></blockquote>
+
+<p>Auditors are called with the arguments:
+
+<blockquote><pre><small
+>audit(db, cl, itemid, newdata)</small></pre></blockquote>
+
+where <tt>db</tt> is the database, <tt>cl</tt> is an
+instance of Class or ItemClass within the database, and <tt>newdata</tt>
+is a dictionary mapping property names to values.
+
+For a <tt>create()</tt>
+operation, the <tt>itemid</tt> argument is <tt>None</tt> and <tt>newdata</tt>
+contains all of the initial property values with which the item
+is about to be created.
+
+For a <tt>set()</tt> operation, <tt>newdata</tt>
+contains only the names and values of properties that are about
+to be changed.
+
+For a <tt>retire()</tt> operation, <tt>newdata</tt> is <tt>None</tt>.
+
+<p>Reactors are called with the arguments:
+
+<blockquote><pre><small
+>react(db, cl, itemid, olddata)</small></pre></blockquote>
+
+where <tt>db</tt> is the database, <tt>cl</tt> is an
+instance of Class or ItemClass within the database, and <tt>olddata</tt>
+is a dictionary mapping property names to values.
+
+For a <tt>create()</tt>
+operation, the <tt>itemid</tt> argument is the id of the
+newly-created item and <tt>olddata</tt> is None.
+
+For a <tt>set()</tt> operation, <tt>olddata</tt>
+contains the names and previous values of properties that were changed.
+
+For a <tt>retire()</tt> operation, <tt>itemid</tt> is the
+id of the retired item and <tt>olddata</tt> is <tt>None</tt>.
+
+<h3>5.2. Detector Example</h3>
+
+<p>Here is an example of detectors written for a hypothetical
+project-management application, where users can signal approval
+of a project by adding themselves to an "approvals" list, and
+a project proceeds when it has three approvals.
+
+<blockquote><pre><small
+># Permit users only to add themselves to the "approvals" list.
+
+def check_approvals(db, cl, id, newdata):
+    if newdata.has_key("approvals"):
+        if cl.get(id, "status") == db.status.lookup("approved"):
+            raise Reject, "You can't modify the approvals list " \
+                          "for a project that has already been approved."
+        old = cl.get(id, "approvals")
+        new = newdata["approvals"]
+        for uid in old:
+            if uid not in new and uid != db.getuid():
+                raise Reject, "You can't remove other users from the "
+                              "approvals list; you can only remove yourself."
+        for uid in new:
+            if uid not in old and uid != db.getuid():
+                raise Reject, "You can't add other users to the approvals "
+                              "list; you can only add yourself."
+
+# When three people have approved a project, change its
+# status from "pending" to "approved".
+
+def approve_project(db, cl, id, olddata):
+    if olddata.has_key("approvals") and len(cl.get(id, "approvals")) == 3:
+        if cl.get(id, "status") == db.status.lookup("pending"):
+            cl.set(id, status=db.status.lookup("approved"))
+
+def init(db):
+    db.project.audit("set", check_approval)
+    db.project.react("set", approve_project)</small
+></pre></blockquote>    
+
+<p>Here is another example of a detector that can allow or prevent
+the creation of new items.  In this scenario, patches for a software
+project are submitted by sending in e-mail with an attached file,
+and we want to ensure that there are <tt>text/plain</tt> attachments on
+the message.  The maintainer of the package can then apply the
+patch by setting its status to "applied".
+
+<blockquote><pre><small
+># Only accept attempts to create new patches that come with patch files.
+
+def check_new_patch(db, cl, id, newdata):
+    if not newdata["files"]:
+        raise Reject, "You can't submit a new patch without " \
+                      "attaching a patch file."
+    for fileid in newdata["files"]:
+        if db.file.get(fileid, "type") != "text/plain":
+            raise Reject, "Submitted patch files must be text/plain."
+
+# When the status is changed from "approved" to "applied", apply the patch.
+
+def apply_patch(db, cl, id, olddata):
+    if cl.get(id, "status") == db.status.lookup("applied") and \
+        olddata["status"] == db.status.lookup("approved"):
+        # ...apply the patch...
+
+def init(db):
+    db.patch.audit("create", check_new_patch)
+    db.patch.react("set", apply_patch)</small
+></pre></blockquote>
+
+<p><hr>
+<h2>6. Command Interface</h2>
+
+<p>The command interface is a very simple and minimal interface,
+intended only for quick searches and checks from the shell prompt.
+(Anything more interesting can simply be written in Python using
+the Roundup database module.)
+
+<h3>6.1. Interface Specification</h3>
+
+<p>A single command, <tt>roundup</tt>, provides basic access to
+the hyperdatabase from the command line.
+
+<ul>
+<li><tt>roundup&nbsp;get&nbsp;</tt>[<tt>-list</tt>]<tt>&nbsp;</tt
+><em>designator</em>[<tt>,</tt
+><em>designator</em><tt>,</tt>...]<tt>&nbsp;</tt><em>propname</em>
+<li><tt>roundup&nbsp;set&nbsp;</tt><em>designator</em>[<tt>,</tt
+><em>designator</em><tt>,</tt>...]<tt>&nbsp;</tt><em>propname</em
+><tt>=</tt><em>value</em> ...
+<li><tt>roundup&nbsp;find&nbsp;</tt>[<tt>-list</tt>]<tt>&nbsp;</tt
+><em>classname</em><tt>&nbsp;</tt><em>propname</em>=<em>value</em> ...
+</ul>
+
+<p>Property values are represented as strings in command arguments
+and in the printed results:
+
+<ul>
+<li>Strings are, well, strings.
+
+<li>Date values are printed in the full date format in the local
+time zone, and accepted in the full format or any of the partial
+formats explained above.
+
+<li>Link values are printed as item designators.  When given as
+an argument, item designators and key strings are both accepted.
+
+<li>Multilink values are printed as lists of item designators
+joined by commas.  When given as an argument, item designators
+and key strings are both accepted; an empty string, a single item,
+or a list of items joined by commas is accepted.
+</ul>
+
+<p>When multiple items are specified to the
+<tt>roundup&nbsp;get</tt> or <tt>roundup&nbsp;set</tt>
+commands, the specified properties are retrieved or set
+on all the listed items.
+
+<p>When multiple results are returned by the <tt>roundup&nbsp;get</tt>
+or <tt>roundup&nbsp;find</tt> commands, they are printed one per
+line (default) or joined by commas (with the <tt>-list</tt>) option.
+
+<h3>6.2. Usage Example</h3>
+
+<p>To find all messages regarding in-progress issues that
+contain the word "spam", for example, you could execute the
+following command from the directory where the database
+dumps its files:
+
+<blockquote><pre><small
+>shell% <span class="input">for issue in `roundup find issue status=in-progress`; do</span>
+&gt; <span class="input">grep -l spam `roundup get $issue messages`</span>
+&gt; <span class="input">done</span>
+<span class="output">msg23
+msg49
+msg50
+msg61</span>
+shell%</small></pre></blockquote>
+
+<p>Or, using the <tt>-list</tt> option, this can be written as a single command:
+
+<blockquote><pre><small
+>shell% <span class="input">grep -l spam `roundup get \
+    \`roundup find -list issue status=in-progress\` messages`</span>
+<span class="output">msg23
+msg49
+msg50
+msg61</span>
+shell%</small></pre></blockquote>
+    
+<p><hr>
+<h2>7. E-mail User Interface</h2>
+
+<p>The Roundup system must be assigned an e-mail address
+at which to receive mail.  Messages should be piped to
+the Roundup mail-handling script by the mail delivery
+system (e.g. using an alias beginning with "|" for sendmail).
+
+<h3>7.1. Message Processing</h3>
+
+<p>Incoming messages are examined for multiple parts.
+In a <tt>multipart/mixed</tt> message or part, each subpart is
+extracted and examined.  In a <tt>multipart/alternative</tt>
+message or part, we look for a <tt>text/plain</tt> subpart and
+ignore the other parts.  The <tt>text/plain</tt> subparts are
+assembled to form the textual body of the message, to
+be stored in the file associated with a "msg" class item.
+Any parts of other types are each stored in separate
+files and given "file" class items that are linked to
+the "msg" item.
+
+<p>The "summary" property on message items is taken from
+the first non-quoting section in the message body.
+The message body is divided into sections by blank lines.
+Sections where the second and all subsequent lines begin
+with a "&gt;" or "|" character are considered "quoting
+sections".  The first line of the first non-quoting 
+section becomes the summary of the message.
+
+<p>All of the addresses in the To: and Cc: headers of the
+incoming message are looked up among the user items, and
+the corresponding users are placed in the "recipients"
+property on the new "msg" item.  The address in the From:
+header similarly determines the "author" property of the
+new "msg" item.
+The default handling for
+addresses that don't have corresponding users is to create
+new users with no passwords and a username equal to the
+address.  (The web interface does not permit logins for
+users with no passwords.)  If we prefer to reject mail from
+outside sources, we can simply register an auditor on the
+"user" class that prevents the creation of user items with
+no passwords.
+
+<p>The subject line of the incoming message is examined to
+determine whether the message is an attempt to create a new
+item or to discuss an existing item.  A designator enclosed
+in square brackets is sought as the first thing on the
+subject line (after skipping any "Fwd:" or "Re:" prefixes).
+
+<p>If an item designator (class name and id number) is found
+there, the newly created "msg" item is added to the "messages"
+property for that item, and any new "file" items are added to
+the "files" property for the item.
+
+<p>If just an item class name is found there, we attempt to
+create a new item of that class with its "messages" property
+initialized to contain the new "msg" item and its "files"
+property initialized to contain any new "file" items.
+
+<p>Both cases may trigger detectors (in the first case we
+are calling the <tt>set()</tt> method to add the message to the
+item's spool; in the second case we are calling the
+<tt>create()</tt> method to create a new item).  If an auditor
+raises an exception, the original message is bounced back to
+the sender with the explanatory message given in the exception.
+
+<h3>7.2. Nosy Lists</h3>
+
+<p>A standard detector is provided that watches for additions
+to the "messages" property.  When a new message is added, the
+detector sends it to all the users on the "nosy" list for the
+item that are not already on the "recipients" list of the
+message.  Those users are then appended to the "recipients"
+property on the message, so multiple copies of a message
+are never sent to the same user.  The journal recorded by
+the hyperdatabase on the "recipients" property then provides
+a log of when the message was sent to whom.
+
+<h3>7.3. Setting Properties</h3>
+
+<p>The e-mail interface also provides a simple way to set
+properties on items.  At the end of the subject line,
+<em>propname</em><tt>=</tt><em>value</em> pairs can be
+specified in square brackets, using the same conventions
+as for the <tt>roundup&nbsp;set</tt> shell command.
+
+<p><hr>
+<h2>8. Web User Interface</h2>
+
+<p>The web interface is provided by a CGI script that can be
+run under any web server.  A simple web server can easily be
+built on the standard <tt>CGIHTTPServer</tt> module, and
+should also be included in the distribution for quick
+out-of-the-box deployment.
+
+<p>The user interface is constructed from a number of template
+files containing mostly HTML.  Among the HTML tags in templates
+are interspersed some nonstandard tags, which we use as
+placeholders to be replaced by properties and their values.
+
+<h3>8.1. Views and View Specifiers</h3>
+
+<p>There are two main kinds of views: index views and item views.
+An index view displays a list of items of a particular class,
+optionally sorted and filtered as requested.  An item view
+presents the properties of a particular item for editing
+and displays the message spool for the item.
+
+<p>A <em>view specifier</em> is a string that specifies
+all the options needed to construct a particular view.
+It goes after the URL to the Roundup CGI script or the
+web server to form the complete URL to a view.  When the
+result of selecting a link or submitting a form takes
+the user to a new view, the Web browser should be redirected
+to a canonical location containing a complete view specifier
+so that the view can be bookmarked.
+
+<h3>8.2. Displaying Properties</h3>
+
+<p>Properties appear in the user interface in three contexts:
+in indices, in editors, and as filters.  For each type of
+property, there are several display possibilities.  For example,
+in an index view, a string property may just be printed as
+a plain string, but in an editor view, that property should
+be displayed in an editable field.
+
+<p>The display of a property is handled by functions in
+a <tt>displayers</tt> module.  Each function accepts at
+least three standard arguments -- the database, class name,
+and item id -- and returns a chunk of HTML.
+
+<p>Displayer functions are triggered by <tt>&lt;display&gt;</tt>
+tags in templates.  The <tt>call</tt> attribute of the tag
+provides a Python expression for calling the displayer
+function.  The three standard arguments are inserted in
+front of the arguments given.  For example, the occurrence of
+
+<blockquote><pre><small
+>    &lt;display call="plain('status', max=30)"&gt;
+</small></pre></blockquote>
+
+in a template triggers a call to
+    
+<blockquote><pre><small
+>    plain(db, "issue", 13, "status", max=30)
+</small></pre></blockquote>
+
+when displaying item 13 in the "issue" class.  The displayer
+functions can accept extra arguments to further specify
+details about the widgets that should be generated.  By defining new
+displayer functions, the user interface can be highly customized.
+
+<p>Some of the standard displayer functions include:
+
+<ul>
+<li><strong>plain</strong>: display a String property directly;
+display a Date property in a specified time zone with an option
+to omit the time from the date stamp; for a Link or Multilink
+property, display the key strings of the linked items (or the
+ids if the linked class has no key property)
+
+<li><strong>field</strong>: display a property like the
+<strong>plain</strong> displayer above, but in a text field
+to be edited
+
+<li><strong>menu</strong>: for a Link property, display
+a menu of the available choices
+
+<li><strong>link</strong>: for a Link or Multilink property,
+display the names of the linked items, hyperlinked to the
+item views on those items
+
+<li><strong>count</strong>: for a Multilink property, display
+a count of the number of links in the list
+
+<li><strong>reldate</strong>: display a Date property in terms
+of an interval relative to the current date (e.g. "+ 3w", "- 2d").
+
+<li><strong>download</strong>: show a Link("file") or Multilink("file")
+property using links that allow you to download files
+
+<li><strong>checklist</strong>: for a Link or Multilink property,
+display checkboxes for the available choices to permit filtering
+</ul>
+
+<h3>8.3. Index Views</h3>
+
+<p>An index view contains two sections: a filter section
+and an index section.
+The filter section provides some widgets for selecting
+which items appear in the index.  The index section is
+a table of items.
+
+<h4>8.3.1. Index View Specifiers</h4>
+
+<p>An index view specifier looks like this (whitespace
+has been added for clarity):
+
+<blockquote><pre><small
+>/issue?status=unread,in-progress,resolved&amp;
+        topic=security,ui&amp;
+        :group=+priority&amp;
+        :sort=-activity&amp;
+        :filters=status,topic&amp;
+        :columns=title,status,fixer
+</small></pre></blockquote>
+
+<p>The index view is determined by two parts of the
+specifier: the layout part and the filter part.
+The layout part consists of the query parameters that
+begin with colons, and it determines the way that the
+properties of selected items are displayed.
+The filter part consists of all the other query parameters,
+and it determines the criteria by which items 
+are selected for display.
+
+<p>The filter part is interactively manipulated with
+the form widgets displayed in the filter section.  The
+layout part is interactively manipulated by clicking
+on the column headings in the table.
+
+<p>The filter part selects the <em>union</em> of the
+sets of items with values matching any specified Link
+properties and the <em>intersection</em> of the sets
+of items with values matching any specified Multilink
+properties.
+
+<p>The example specifies an index of "issue" items.
+Only items with a "status" of <em>either</em>
+"unread" or "in-progres" or "resolved" are displayed,
+and only items with "topic" values including <em>both</em>
+"security" <em>and</em> "ui" are displayed.  The items
+are grouped by priority, arranged in ascending order;
+and within groups, sorted by activity, arranged in
+descending order.  The filter section shows filters
+for the "status" and "topic" properties, and the
+table includes columns for the "title", "status", and
+"fixer" properties.
+
+<p>Associated with each item class is a default
+layout specifier.  The layout specifier in the above
+example is the default layout to be provided with
+the default bug-tracker schema described above in
+section 4.4.
+
+<h4>8.3.2. Filter Section</h4>
+
+<p>The template for a filter section provides the
+filtering widgets at the top of the index view.
+Fragments enclosed in <tt>&lt;property&gt;</tt>...<tt>&lt;/property&gt;</tt>
+tags are included or omitted depending on whether the
+view specifier requests a filter for a particular property.
+
+<p>Here's a simple example of a filter template.
+
+<blockquote><pre><small
+>&lt;property name=status&gt;
+    &lt;display call="checklist('status')"&gt;
+&lt;/property&gt;
+&lt;br&gt;
+&lt;property name=priority&gt;
+    &lt;display call="checklist('priority')"&gt;
+&lt;/property&gt;
+&lt;br&gt;
+&lt;property name=fixer&gt;
+    &lt;display call="menu('fixer')"&gt;
+&lt;/property&gt;</small></pre></blockquote>
+
+<h4>8.3.3. Index Section</h4>
+
+<p>The template for an index section describes one row of
+the index table.
+Fragments enclosed in <tt>&lt;property&gt;</tt>...<tt>&lt;/property&gt;</tt>
+tags are included or omitted depending on whether the
+view specifier requests a column for a particular property.
+The table cells should contain <tt>&lt;display&gt;</tt> tags
+to display the values of the item's properties.
+
+<p>Here's a simple example of an index template.
+
+<blockquote><pre><small
+>&lt;tr&gt;
+    &lt;property name=title&gt;
+        &lt;td&gt;&lt;display call="plain('title', max=50)"&gt;&lt;/td&gt;
+    &lt;/property&gt;
+    &lt;property name=status&gt;
+        &lt;td&gt;&lt;display call="plain('status')"&gt;&lt;/td&gt;
+    &lt;/property&gt;
+    &lt;property name=fixer&gt;
+        &lt;td&gt;&lt;display call="plain('fixer')"&gt;&lt;/td&gt;
+    &lt;/property&gt;
+&lt;/tr&gt;</small></pre></blockquote>
+
+<h4>8.3.4. Sorting</h4>
+
+<p>String and Date values are sorted in the natural way.
+Link properties are sorted according to the value of the
+"order" property on the linked items if it is present; or
+otherwise on the key string of the linked items; or
+finally on the item ids.  Multilink properties are
+sorted according to how many links are present.
+
+<h3>8.4. Item Views</h3>
+
+<p>An item view contains an editor section and a spool section.
+At the top of an item view, links to superseding and superseded
+items are always displayed.
+
+<h4>8.4.1. Item View Specifiers</h4>
+
+<p>An item view specifier is simply the item's designator:
+
+<blockquote><pre><small
+>/patch23
+</small></pre></blockquote>
+
+<h4>8.4.2. Editor Section</h4>
+
+<p>The editor section is generated from a template
+containing <tt>&lt;display&gt;</tt> tags to insert
+the appropriate widgets for editing properties.
+
+<p>Here's an example of a basic editor template.
+
+<blockquote><pre><small
+>&lt;table&gt;
+&lt;tr&gt;
+    &lt;td colspan=2&gt;
+        &lt;display call="field('title', size=60)"&gt;
+    &lt;/td&gt;
+&lt;/tr&gt;
+&lt;tr&gt;
+    &lt;td&gt;
+        &lt;display call="field('fixer', size=30)"&gt;
+    &lt;/td&gt;
+    &lt;td&gt;
+        &lt;display call="menu('status')&gt;
+    &lt;/td&gt;
+&lt;/tr&gt;
+&lt;tr&gt;
+    &lt;td&gt;
+        &lt;display call="field('nosy', size=30)"&gt;
+    &lt;/td&gt;
+    &lt;td&gt;
+        &lt;display call="menu('priority')&gt;
+    &lt;/td&gt;
+&lt;/tr&gt;
+&lt;tr&gt;
+    &lt;td colspan=2&gt;
+        &lt;display call="note()"&gt;
+    &lt;/td&gt;
+&lt;/tr&gt;
+&lt;/table&gt;
+</small></pre></blockquote>
+
+<p>As shown in the example, the editor template can also
+request the display of a "note" field, which is a
+text area for entering a note to go along with a change.
+
+<p>When a change is submitted, the system automatically
+generates a message describing the changed properties.
+The message displays all of the property values on the
+item and indicates which ones have changed.
+An example of such a message might be this:
+
+<blockquote><pre><small
+>title: Polly Parrot is dead
+priority: critical
+status: unread -&gt; in-progress
+fixer: (none)
+keywords: parrot,plumage,perch,nailed,dead
+</small></pre></blockquote>
+
+<p>If a note is given in the "note" field, the note is
+appended to the description.  The message is then added
+to the item's message spool (thus triggering the standard
+detector to react by sending out this message to the nosy list).
+
+<h4>8.4.3. Spool Section</h4>
+
+<p>The spool section lists messages in the item's "messages"
+property.  The index of messages displays the "date", "author",
+and "summary" properties on the message items, and selecting a
+message takes you to its content.
+
+<p><hr>
+<h2>9. Deployment Scenarios</h2>
+
+<p>The design described above should be general enough
+to permit the use of Roundup for bug tracking, managing
+projects, managing patches, or holding discussions.  By
+using items of multiple types, one could deploy a system
+that maintains requirement specifications, catalogs bugs,
+and manages submitted patches, where patches could be
+linked to the bugs and requirements they address.
+
+<p><hr>
+<h2>10. Acknowledgements</h2>
+
+<p>My thanks are due to Christy Heyl for 
+reviewing and contributing suggestions to this paper
+and motivating me to get it done, and to
+Jesse Vincent, Mark Miller, Christopher Simons,
+Jeff Dunmall, Wayne Gramlich, and Dean Tribble for
+their assistance with the first-round submission.
+</td>
+</tr>
+</table>
+
+<p>
+
+<center>
+<table>
+<tr>
+<td>&nbsp;&nbsp;&nbsp;<a href="http://www.software-carpentry.com/index.html"><b>[Home]</b></a>&nbsp;&nbsp;&nbsp;</td>
+<td>&nbsp;&nbsp;&nbsp;<a href="http://www.software-carpentry.com/faq.html"><b>[FAQ]</b></a>&nbsp;&nbsp;&nbsp;</td>
+<td>&nbsp;&nbsp;&nbsp;<a href="http://www.software-carpentry.com/license.html"><b>[License]</b></a>&nbsp;&nbsp;&nbsp;</td>
+<td>&nbsp;&nbsp;&nbsp;<a href="http://www.software-carpentry.com/contest-rules.html"><b>[Rules]</b></a>&nbsp;&nbsp;&nbsp;</td>
+<td>&nbsp;&nbsp;&nbsp;<a href="http://www.software-carpentry.com/sc_config/"><b>[Configure]</b></a>&nbsp;&nbsp;&nbsp;</td>
+<td>&nbsp;&nbsp;&nbsp;<a href="http://www.software-carpentry.com/sc_build/"><b>[Build]</b></a>&nbsp;&nbsp;&nbsp;</td>
+<td>&nbsp;&nbsp;&nbsp;<a href="http://www.software-carpentry.com/sc_test/"><b>[Test]</b></a>&nbsp;&nbsp;&nbsp;</td>
+<td>&nbsp;&nbsp;&nbsp;<a href="http://www.software-carpentry.com/sc_track/"><b>[Track]</b></a>&nbsp;&nbsp;&nbsp;</td>
+<td>&nbsp;&nbsp;&nbsp;<a href="http://www.software-carpentry.com/biblio.html"><b>[Resources]</b></a>&nbsp;&nbsp;&nbsp;</td>
+<td>&nbsp;&nbsp;&nbsp;<a href="http://www.software-carpentry.com/lists/"><b>[Archives]</b></a>&nbsp;&nbsp;&nbsp;</td>
+</tr>
+</table>
+</center>
+
+<p><hr>
+<center>Last modified 2001/04/06 11:50:59.9063 US/Mountain</center>
+</BODY>
+</HTML>

Added: tracker/vendor/roundup/current/doc/tracker_templates.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/tracker_templates.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,42 @@
+=========================
+Roundup Tracker Templates
+=========================
+
+:Version: $Revision: 1.2 $
+
+The templates distributed with Roundup are stored in the "share" directory
+nominated by Python. On Unix this is typically
+``/usr/share/roundup/templates/`` (or ``/usr/local/share...``) and
+on Windows this is ``c:\python22\share\roundup\templates\``.
+
+The template loading looks in four places to find the templates:
+
+1. *share* - eg. ``<prefix>/share/roundup/templates/*``.
+   This should be the standard place to find them when Roundup is
+   installed.
+2. ``<roundup.admin.__file__>/../templates/*``.
+   This will be used if Roundup's run in the distro (aka. source)
+   directory.
+3. ``<current working dir>/*``.
+   This is for when someone unpacks a 3rd-party template.
+4. ``<current working dir>``.
+   This is for someone who "cd"s to the 3rd-party template dir.
+
+Templates contain:
+
+- modules ``schema.py`` and ``initial_data.py``
+- directories ``html``, ``detectors`` and ``extensions``
+  (with appropriate contents)
+- template "marker" file ``TEMPLATE-INFO.txt``, which contains
+  the name of the template, a description of the template
+  and its intended audience.
+
+An example TEMPLATE-INFO.txt::
+
+ Name: classic
+ Description: This is a generic issue tracker that may be used to track bugs,
+              feature requests, project issues or any number of other types
+              of issues. Most users of Roundup will find that this template
+              suits them, with perhaps a few customisations.
+ Intended-For: All first-time Roundup users
+

Added: tracker/vendor/roundup/current/doc/upgrading.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/upgrading.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,1309 @@
+======================================
+Upgrading to newer versions of Roundup
+======================================
+
+Please read each section carefully and edit your tracker home files
+accordingly. Note that there is information about upgrade procedures in the
+`administration guide`_.
+
+If a specific version transition isn't mentioned here (eg. 0.6.7 to 0.6.8)
+then you don't need to do anything. If you're upgrading from 0.5.6 to
+0.6.8 though, you'll need to check the "0.5 to 0.6" and "0.6.x to 0.6.3"
+steps.
+
+.. contents::
+
+Migrating from 1.1.0 to 1.1.1
+=============================
+
+1.1.1 "Clear this message"
+--------------------------
+
+In 1.1.1, the standard ``page.html`` template includes a "clear this message"
+link in the green "ok" message bar that appears after a successful edit
+(or other) action.
+
+To include this in your tracker, change the following in your ``page.html``
+template::
+
+ <p tal:condition="options/ok_message | nothing" class="ok-message"
+    tal:repeat="m options/ok_message" tal:content="structure m">error</p>
+
+to be::
+
+ <p tal:condition="options/ok_message | nothing" class="ok-message">
+   <span tal:repeat="m options/ok_message"
+      tal:content="structure string:$m <br/ > " />
+    <a class="form-small" tal:attributes="href request/current_url"
+       i18n:translate="">clear this message</a>
+ </p>
+
+
+If you implemented the "clear this message" in your 1.1.0 tracker, then you
+should change it to the above and it will work much better!
+
+
+Migrating from 1.0.x to 1.1.0
+=============================
+
+1.1 Login "For Session Only"
+----------------------------
+
+In 1.1, web logins are alive for the length of a session only, *unless* you
+add the following to the login form in your tracker's ``page.html``::
+
+    <input type="checkbox" name="remember" id="remember">
+    <label for="remember" i18n:translate="">Remember me?</label><br>
+
+See the classic tracker ``page.html`` if you're unsure where this should
+go.
+
+
+1.1 Query Display Name
+----------------------
+
+The ``dispname`` web variable has been renamed ``@dispname`` to avoid
+clashing with other variables of the same name. If you are using the
+display name feature, you will need to edit your tracker's ``page.html``
+and ``issue.index.html`` pages to change ``dispname`` to ``@dispname``.
+
+A side-effect of this change is that the renderWith method used in the
+``home.html`` page may now take a dispname argument.
+
+
+1.1 "Clear this message"
+------------------------
+
+In 1.1, the standard ``page.html`` template includes a "clear this message"
+link in the green "ok" message bar that appears after a successful edit
+(or other) action.
+
+To include this in your tracker, change the following in your ``page.html``
+template::
+
+ <p tal:condition="options/ok_message | nothing" class="ok-message"
+    tal:repeat="m options/ok_message" tal:content="structure m">error</p>
+
+to be::
+
+ <p tal:condition="options/ok_message | nothing" class="ok-message">
+   <span tal:repeat="m options/ok_message"
+      tal:content="structure string:$m <br/ > " />
+    <a class="form-small" tal:attributes="href string:issue${context/id}"
+       i18n:translate="">clear this message</a>
+ </p>
+
+
+Migrating from 0.8.x to 1.0
+===========================
+
+1.0 New Query Permissions
+-------------------------
+
+New permissions are defined for query editing and viewing. To include these
+in your tracker, you need to add these lines to your tracker's
+``schema.py``::
+
+ # Users should be able to edit and view their own queries. They should also
+ # be able to view any marked as not private. They should not be able to
+ # edit others' queries, even if they're not private
+ def view_query(db, userid, itemid):
+     private_for = db.query.get(itemid, 'private_for')
+     if not private_for: return True
+     return userid == private_for
+ def edit_query(db, userid, itemid):
+     return userid == db.query.get(itemid, 'creator')
+ p = db.security.addPermission(name='View', klass='query', check=view_query,
+     description="User is allowed to view their own and public queries")
+ db.security.addPermissionToRole('User', p)
+ p = db.security.addPermission(name='Edit', klass='query', check=edit_query,
+     description="User is allowed to edit their queries")
+ db.security.addPermissionToRole('User', p)
+ p = db.security.addPermission(name='Create', klass='query',
+     description="User is allowed to create queries")
+ db.security.addPermissionToRole('User', p)
+
+and then remove 'query' from the line::
+
+ # Assign the access and edit Permissions for issue, file and message
+ # to regular users now
+ for cl in 'issue', 'file', 'msg', 'query', 'keyword':
+
+so it looks like::
+
+ for cl in 'issue', 'file', 'msg', 'keyword':
+
+
+Migrating from 0.8.0 to 0.8.3
+=============================
+
+0.8.3 Nosy Handling Changes
+---------------------------
+
+A change was made to fix a bug in the ``nosyreaction.py`` standard
+detector. To incorporate this fix in your trackers, you will need to copy
+the ``nosyreaction.py`` file from the ``templates/classic/detectors``
+directory of the source to your tracker's ``templates`` directory.
+
+If you have modified the ``nosyreaction.py`` file from the standard
+version, you will need to roll your changes into the new file.
+
+
+Migrating from 0.7.1 to 0.8.0
+=============================
+
+You *must* fully uninstall previous Roundup version before installing
+Roundup 0.8.0.  If you don't do that, ``roundup-admin install``
+command may fail to function properly.
+
+0.8.0 Backend changes
+---------------------
+
+Backends 'bsddb' and 'bsddb3' are removed.  If you are using one of these,
+you *must* migrate to another backend before upgrading.
+
+
+0.8.0 API changes
+-----------------
+
+Class.safeget() was removed from the API. Test your item ids before calling
+Class.get() instead.
+
+
+0.8.0 New tracker layout
+------------------------
+
+The ``config.py`` file has been replaced by ``config.ini``. You may use the
+roundup-admin command "genconfig" to generate a new config file::
+
+  roundup-admin genconfig <tracker home>/config.ini
+
+and modify the values therein based on the contents of your old config.py.
+In most cases, the names of the config variables are the same.
+
+The ``select_db.py`` file has been replaced by a file in the ``db``
+directory called ``backend_name``. As you might guess, this file contains
+just the name of the backend. To figure what the contents of yours should
+be, use the following table:
+
+  ================================ =========================
+  ``select_db.py`` contents        ``backend_name`` contents
+  ================================ =========================
+  from back_anydbm import ...      anydbm
+  from back_metakit import ...     metakit
+  from back_sqlite import ...      sqlite
+  from back_mysql import ...       mysql
+  from back_postgresql import ...  postgresql
+  ================================ =========================
+
+The ``dbinit.py`` file has been split into two new files,
+``initial_data.py`` and ``schema.py``. The contents of this file are:
+
+``initial_data.py``
+  You don't need one of these as your tracker is already initialised.
+
+``schema.py``
+  Copy the body of the ``def open(name=None)`` function from your old
+  tracker's ``dbinit.py`` file to this file. As the lines you're copying
+  aren't part of a function definition anymore, one level of indentation
+  needs to be removed (remove only the leading four spaces on each
+  line). 
+
+  The first few lines -- those starting with ``from roundup.hyperdb
+  import ...`` and the ``db = Database(config, name)`` line -- don't
+  need to be copied. Neither do the last few lines -- those starting
+  with ``import detectors``, down to ``return db`` inclusive.
+
+You may remove the ``__init__.py`` module from the "detectors" directory as
+it is no longer used.
+
+There's a new way to write extension code for Roundup - the old
+``interfaces.py`` file will be ignored. See the `customisation
+documentation`_ for information about how extensions are now written.
+
+
+0.8.0 Permissions Changes
+-------------------------
+
+The creation of a new item in the user interfaces is now controlled by the
+"Create" Permission. You will need to add an assignment of this Permission
+to your users who are allowed to create items. The most common form of this
+is the following in your ``schema.py`` added just under the current
+assignation of the Edit Permission::
+
+    for cl in 'issue', 'file', 'msg', 'query', 'keyword':
+        p = db.security.getPermission('Create', cl)
+        db.security.addPermissionToRole('User', p)
+
+You will need to explicitly let anonymous users access the web interface so
+that regular users are able to see the login form. Note that almost all
+trackers will need this Permission. The only situation where it's not
+required is in a tracker that uses an HTTP Basic Authenticated front-end.
+It's enabled by adding to your ``schema.py``::
+
+    p = db.security.getPermission('Web Access')
+    db.security.addPermissionToRole('Anonymous', p)
+
+Finally, you will need to enable permission for your users to edit their
+own details by adding the following to ``schema.py``::
+
+    # Users should be able to edit their own details. Note that this
+    # permission is limited to only the situation where the Viewed or
+    # Edited item is their own.
+    def own_record(db, userid, itemid):
+        '''Determine whether the userid matches the item being accessed.'''
+        return userid == itemid
+    p = db.security.addPermission(name='View', klass='user', check=own_record,
+        description="User is allowed to view their own user details")
+    p = db.security.addPermission(name='Edit', klass='user', check=own_record,
+        description="User is allowed to edit their own user details")
+    db.security.addPermissionToRole('User', p)
+
+
+0.8.0 Use of TemplatingUtils
+----------------------------
+
+If you used custom python functions in TemplatingUtils, they must
+be moved from interfaces.py to a new file in the ``extensions`` directory. 
+
+Each Function that should be available through TAL needs to be defined
+as a toplevel function in the newly created file. Furthermore you
+add an inititialization function, that registers the functions with the 
+tracker.
+
+If you find this too tedious, donfu wrote an automatic init function that
+takes an existing TemplatingUtils class, and registers all class methods
+that do not start with an underscore. The following hack should be placed
+in the ``extensions`` directory alongside other extensions::
+
+    class TemplatingUtils:
+         # copy from interfaces.py
+
+    def init(tracker):
+         util = TemplatingUtils()
+
+         def setClient(tu):
+             util.client = tu.client
+             return util
+
+         def execUtil(name):
+             return lambda tu, *args, **kwargs: \
+                     getattr(setClient(tu), name)(*args, **kwargs)
+
+         for name in dir(util):
+             if callable(getattr(util, name)) and not name.startswith('_'):
+                  tracker.registerUtil(name, execUtil(name))
+
+
+0.8.0 Logging Configuration
+---------------------------
+
+See the `administration guide`_ for information about configuring the new
+logging implemented in 0.8.0.
+
+
+Migrating from 0.7.2 to 0.7.3
+=============================
+
+0.7.3 Configuration
+-------------------
+
+If you choose, you may specify the directory from which static files are
+served (those which use the URL component ``@@file``). Currently the
+directory defaults to the ``TEMPLATES`` configuration variable. You may
+define a new variable, ``STATIC_FILES`` which overrides this value for
+static files.
+
+
+Migrating from 0.7.0 to 0.7.2
+=============================
+
+0.7.2 DEFAULT_TIMEZONE is now required
+--------------------------------------
+
+The DEFAULT_TIMEZONE configuration variable is now required. Add the
+following to your tracker's ``config.py`` file::
+
+    # You may specify a different default timezone, for use when users do not
+    # choose their own in their settings.
+    DEFAULT_TIMEZONE = 0            # specify as numeric hour offest
+
+
+Migrating from 0.7.0 to 0.7.1
+=============================
+
+0.7.1 Permission assignments
+----------------------------
+
+If you allow anonymous access to your tracker, you might need to assign
+some additional View (or Edit if your tracker is that open) permissions
+to the "anonymous" user. To do so, find the code in your ``dbinit.py`` that
+says::
+
+    for cl in 'issue', 'file', 'msg', 'query', 'keyword':
+        p = db.security.getPermission('View', cl)
+        db.security.addPermissionToRole('User', p)
+        p = db.security.getPermission('Edit', cl)
+        db.security.addPermissionToRole('User', p)
+    for cl in 'priority', 'status':
+        p = db.security.getPermission('View', cl)
+        db.security.addPermissionToRole('User', p)
+
+Add add a line::
+
+        db.security.addPermissionToRole('Anonymous', p)
+
+next to the existing ``'User'`` lines for the Permissions you wish to
+assign to the anonymous user.
+
+
+Migrating from 0.6 to 0.7
+=========================
+
+0.7.0 Permission assignments
+----------------------------
+
+Due to a change in the rendering of web widgets, permissions are now
+checked on Classes where they previously weren't (this is a good thing).
+
+You will need to add some additional Permission assignments for your
+regular users, or some displays will break. After the following in your 
+tracker's ``dbinit.py``::
+
+    # Assign the access and edit Permissions for issue, file and message
+    # to regular users now
+    for cl in 'issue', 'file', 'msg', 'query', 'keyword':
+        p = db.security.getPermission('View', cl)
+        db.security.addPermissionToRole('User', p)
+        p = db.security.getPermission('Edit', cl)
+        db.security.addPermissionToRole('User', p)
+
+add::
+
+    for cl in 'priority', 'status':
+        p = db.security.getPermission('View', cl)
+        db.security.addPermissionToRole('User', p)
+
+
+0.7.0 Getting the current user id
+---------------------------------
+
+The Database.curuserid attribute has been removed.
+
+Any code referencing this attribute should be replaced with a
+call to Database.getuid().
+
+
+0.7.0 ZRoundup changes
+----------------------
+
+The templates in your tracker's html directory will need updating if you
+wish to use ZRoundup. If you've not modified those files (or some of them),
+you may just copy the new versions from the Roundup source in the
+templates/classic/html directory.
+
+If you have modified the html files, then you'll need to manually edit them
+to change all occurances of special form variables from using the colon ":"
+special character to the at "@" special character. That is, variables such
+as::
+
+  :action :required :template :remove:messages ...
+
+should become::
+
+  @action @required @template @remove at messages ...
+
+Note that ``tal:`` statements are unaffected. So are TAL expression type
+prefixes such as ``python:`` and ``string:``. Please ask on the
+roundup-users mailing list for help if you're unsure.
+
+
+0.7.0 Edit collision detection
+------------------------------
+
+Roundup now detects collisions with editing in the web interface (that is,
+two people editing the same item at the same time).
+
+You must copy the ``_generic.collision.html`` file from Roundup source in
+the ``templates/classic/html`` directory. to your tracker's ``html``
+directory.
+
+
+Migrating from 0.6.x to 0.6.3
+=============================
+
+0.6.3 Configuration
+-------------------
+
+You will need to copy the file::
+
+  templates/classic/detectors/__init__.py
+
+to your tracker's ``detectors`` directory, replacing the one already there.
+This fixes a couple of bugs in that file.
+
+
+
+Migrating from 0.5 to 0.6
+=========================
+
+
+0.6.0 Configuration
+-------------------
+
+Introduced EMAIL_FROM_TAG config variable. This value is inserted into
+the From: line of nosy email. If the sending user is "Foo Bar", the
+From: line is usually::
+
+     "Foo Bar" <issue_tracker at tracker.example>
+
+the EMAIL_FROM_TAG goes inside the "Foo Bar" quotes like so::
+
+     "Foo Bar EMAIL_FROM_TAG" <issue_tracker at tracker.example>
+
+I've altered the mechanism in the detectors __init__.py module so that it
+doesn't cross-import detectors from other trackers (if you run more than one
+in a single roundup-server). This change means that you'll need to copy the
+__init__.py from roundup/templates/classic/detectors/__init__.py to your
+<tracker home>/detectors/__init__.py. Don't worry, the "classic" __init__ is a
+one-size-fits-all, so it'll work even if you've added/removed detectors.
+
+0.6.0 Templating changes
+------------------------
+
+The ``user.item`` template (in the tracker home "templates" directory)
+needs to have the following hidden variable added to its form (between the
+``<form...>`` and ``</form>`` tags::
+
+  <input type="hidden" name=":template" value="item">
+
+
+0.6.0 Form handling changes
+---------------------------
+
+Roundup's form handling capabilities have been significantly expanded. This
+should not affect users of 0.5 installations - but if you find you're
+getting errors from form submissions, please ask for help on the Roundup
+users mailing list:
+
+  http://lists.sourceforge.net/lists/listinfo/roundup-users
+
+See the customisation doc section on `Form Values`__ for documentation of the
+new form variables possible.
+
+__ customizing.html#form-values
+
+
+0.6.0 Multilingual character set support
+----------------------------------------
+
+Added internationalization support. This is done via encoding all data
+stored in roundup database to utf-8 (unicode encoding). To support utf-8 in
+web interface you should add the folowing line to your tracker's html/page
+and html/_generic.help files inside <head> tag::
+  
+    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
+
+Since latin characters in utf-8 have the same codes as in ASCII table, this
+modification is optional for users who use only plain latin characters. 
+
+After this modification, you will be able to see and enter any world
+character via web interface. Data received via mail interface also converted
+to utf-8, however only new messages will be converted. If your roundup
+database contains some of non-ASCII characters in one of 8-bit encoding,
+they will not be visible in new unicode environment. Some of such data (e.g.
+user names, keywords, etc)  can be edited by administrator, the others
+(e.g. messages' contents) is not editable via web interface. Currently there
+is no tool for converting such data, the only solution is to close
+appropriate old issues and create new ones with the same content.
+
+
+0.6.0 User timezone support
+---------------------------
+
+From version 0.6.0 roundup supports displaying of Date data in user' local
+timezone if he/she has provided timezone information. To make it possible
+some modification to tracker's schema and HTML templates are required.
+First you must add string property 'timezone' to user class in dbinit.py
+like this::
+  
+    user = Class(db, "user", 
+                    username=String(),   password=Password(),
+                    address=String(),    realname=String(), 
+                    phone=String(),      organisation=String(),
+                    alternate_addresses=String(),
+                    queries=Multilink('query'), roles=String(),
+                    timezone=String())
+  
+And second - html interface. Add following lines to
+$TRACKER_HOME/html/user.item template::
+  
+     <tr>
+      <th>Timezone</th>
+      <td tal:content="structure context/timezone/field">timezone</td>
+     </tr>
+
+After that all users should be able to provide their timezone information.
+Timezone should be a positive or negative integer - offset from GMT.
+
+After providing timezone, roundup will show all dates values, found in web
+and mail interfaces in local time. It will also accept any Date info in
+local time, convert and store it in GMT.
+
+
+0.6.0 Search page structure
+---------------------------
+
+In order to accomodate query editing the search page has been restructured. If
+you want to provide your users with query editing, you should update your
+search page using the macros detailed in the customisation doc section
+`Searching on categories`__.
+
+__ customizing.html#searching-on-categories
+
+Also, the url field in the query class no longer starts with a '?'. You'll need
+to remove this question mark from the url field to support queries. There's
+a script in the "tools" directory called ``migrate-queries.py`` that should
+automatically change any existing queries for you. As always, make a backup
+of your database before running such a script.
+
+
+0.6.0 Notes for metakit backend users
+-------------------------------------
+
+Roundup 0.6.0 introduced searching on ranges of dates and intervals. To
+support it, some modifications to interval storing routine were made. So if
+your tracker uses metakit backend and your db schema contains intervals
+property, searches on that property will not be accurate for db items that
+was stored before roundup' upgrade. However all new records should be
+searchable on intervals.
+
+It is possible to convert your database to new format: you can export and
+import back all your data (consult "Migrating backends" in "Maintenance"
+documentation). After this operation all your interval properties should
+become searchable.
+
+Users of backends others than metakit should not worry about this issue.
+
+
+Migrating from 0.4.x to 0.5.0
+=============================
+
+This has been a fairly major revision of Roundup:
+
+1. Brand new, much more powerful, flexible, tasty and nutritious templating.
+   Unfortunately, this means all your current templates are useless. Hopefully
+   the new documentation and examples will be enough to help you make the
+   transition. Please don't hesitate to ask on roundup-users for help (or
+   complete conversions if you're completely stuck)!
+2. The database backed got a lot more flexible, allowing Metakit and SQL
+   databases! The only decent SQL database implemented at present is sqlite,
+   but others shouldn't be a whole lot more work.
+3. A brand new, highly flexible and much more robust security system including
+   a system of Permissions, Roles and Role assignments to users. You may now
+   define your own Permissions that may be checked in CGI transactions.
+4. Journalling has been made less storage-hungry, so has been turned on
+   by default *except* for author, recipient and nosy link/unlink events. You
+   are advised to turn it off in your trackers too.
+5. We've changed the terminology from "instance" to "tracker", to ease the
+   learning curve/impact for new users.
+6. Because of the above changes, the tracker configuration has seen some
+   major changes. See below for the details.
+
+Please, **back up your database** before you start the migration process. This
+is as simple as copying the "db" directory and all its contents from your
+tracker to somewhere safe.
+
+
+0.5.0 Configuration
+-------------------
+
+First up, rename your ``instance_config.py`` file to just ``config.py``.
+
+Then edit your tracker's ``__init__.py`` module. It'll currently look
+like this::
+
+ from instance_config import *
+ try:
+     from dbinit import *
+ except ImportError:
+     pass # in installdir (probably :)
+ from interfaces import *
+
+and it needs to be::
+
+ import config
+ from dbinit import open, init
+ from interfaces import Client, MailGW
+
+Due to the new templating having a top-level ``page`` that defines links for
+searching, indexes, adding items etc, the following variables are no longer
+used:
+
+- HEADER_INDEX_LINKS
+- HEADER_ADD_LINKS
+- HEADER_SEARCH_LINKS
+- SEARCH_FILTERS
+- DEFAULT_INDEX
+- UNASSIGNED_INDEX
+- USER_INDEX
+- ISSUE_FILTER
+
+The new security implementation will require additions to the dbinit module,
+but also removes the need for the following tracker config variables:
+
+- ANONYMOUS_ACCESS
+- ANONYMOUS_REGISTER
+
+but requires two new variables which define the Roles assigned to users who
+register through the web and e-mail interfaces:
+
+- NEW_WEB_USER_ROLES
+- NEW_EMAIL_USER_ROLES
+
+in both cases, 'User' is a good initial setting. To emulate
+``ANONYMOUS_ACCESS='deny'``, remove all "View" Permissions from the
+"Anonymous" Role. To emulate ``ANONYMOUS_REGISTER='deny'``, remove the "Web
+Registration" and/or the "Email Registration" Permission from the "Anonymous"
+Role. See the section on customising security in the `customisation
+documentation`_ for more information.
+
+Finally, the following config variables have been renamed to make more sense:
+
+- INSTANCE_HOME -> TRACKER_HOME
+- INSTANCE_NAME -> TRACKER_NAME
+- ISSUE_TRACKER_WEB -> TRACKER_WEB
+- ISSUE_TRACKER_EMAIL -> TRACKER_EMAIL
+
+
+0.5.0 Schema Specification
+--------------------------
+
+0.5.0 Database backend changes
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Your select_db module in your tracker has changed a fair bit. Where it used
+to contain::
+
+ # WARNING: DO NOT EDIT THIS FILE!!!
+ from roundup.backends.back_anydbm import Database
+
+it must now contain::
+
+ # WARNING: DO NOT EDIT THIS FILE!!!
+ from roundup.backends.back_anydbm import Database, Class, FileClass, IssueClass
+
+Yes, I realise the irony of the "DO NOT EDIT THIS FILE" statement :)
+Note the addition of the Class, FileClass, IssueClass imports. These are very
+important, as they're going to make the next change work too. You now need to
+modify the top of the dbinit module in your tracker from::
+
+ import instance_config
+ from roundup import roundupdb
+ from select_db import Database
+
+ from roundup.roundupdb import Class, FileClass
+
+ class Database(roundupdb.Database, select_db.Database):
+     ''' Creates a hybrid database from:
+          . the selected database back-end from select_db
+          . the roundup extensions from roundupdb
+     '''
+     pass
+
+ class IssueClass(roundupdb.IssueClass):
+     ''' issues need the email information
+     '''
+     pass
+
+to::
+
+ import config
+ from select_db import Database, Class, FileClass, IssueClass
+
+Yes, remove the Database and IssueClass definitions and those other imports.
+They're not needed any more!
+
+Look for places in dbinit.py where ``instance_config`` is used too, and
+rename them ``config``.
+
+
+0.5.0 Journalling changes
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Journalling has been optimised for storage. Journalling of links has been
+turned back on by default. If your tracker has a large user base, you may wish
+to turn off journalling of nosy list, message author and message recipient
+link and unlink events. You do this by adding ``do_journal='no'`` to the Class
+initialisation in your dbinit. For example, your *msg* class initialisation
+probably looks like this::
+
+    msg = FileClass(db, "msg",
+                    author=Link("user"), recipients=Multilink("user"),
+                    date=Date(),         summary=String(),
+                    files=Multilink("file"),
+                    messageid=String(),  inreplyto=String())
+
+to turn off journalling of author and recipient link events, add
+``do_journal='no'`` to the ``author=Link("user")`` part of the statement,
+like so::
+
+    msg = FileClass(db, "msg",
+                    author=Link("user", do_journal='no'),
+                    recipients=Multilink("user", do_journal='no'),
+                    date=Date(),         summary=String(),
+                    files=Multilink("file"),
+                    messageid=String(),  inreplyto=String())
+
+Nosy list link event journalling is actually turned off by default now. If you
+want to turn it on, change to your issue class' nosy list, change its
+definition from::
+
+    issue = IssueClass(db, "issue",
+                    assignedto=Link("user"), topic=Multilink("keyword"),
+                    priority=Link("priority"), status=Link("status"))
+
+to::
+
+    issue = IssueClass(db, "issue", nosy=Multilink("user", do_journal='yes'),
+                    assignedto=Link("user"), topic=Multilink("keyword"),
+                    priority=Link("priority"), status=Link("status"))
+
+noting that your definition of the nosy Multilink will override the normal one.
+
+
+0.5.0 User schema changes
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Users have two more properties, "queries" and "roles". You'll have something
+like this in your dbinit module now::
+
+    user = Class(db, "user",
+                    username=String(),   password=Password(),
+                    address=String(),    realname=String(),
+                    phone=String(),      organisation=String(),
+                    alternate_addresses=String())
+    user.setkey("username")
+
+and you'll need to add the new properties and the new "query" class to it
+like so::
+
+    query = Class(db, "query",
+                    klass=String(),     name=String(),
+                    url=String())
+    query.setkey("name")
+
+    # Note: roles is a comma-separated string of Role names
+    user = Class(db, "user",
+                    username=String(),   password=Password(),
+                    address=String(),    realname=String(),
+                    phone=String(),      organisation=String(),
+                    alternate_addresses=String(),
+                    queries=Multilink('query'), roles=String())
+    user.setkey("username")
+
+The "queries" property is used to store off the user's favourite database
+queries. The "roles" property is explained below in `0.5.0 Security
+Settings`_.
+
+
+0.5.0 Security Settings
+~~~~~~~~~~~~~~~~~~~~~~~
+
+See the `security documentation`_ for an explanation of how the new security
+system works. In a nutshell though, the security is handled as a four step
+process:
+
+1. Permissions are defined as having a name and optionally a hyperdb class
+   they're specific to,
+2. Roles are defined that have one or more Permissions,
+3. Users are assigned Roles in their "roles" property, and finally
+4. Roundup checks that users have appropriate Permissions at appropriate times
+   (like editing issues).
+
+Your tracker dbinit module's *open* function now has to define any
+Permissions that are specific to your tracker, and also the assignment
+of Permissions to Roles. At the moment, your open function
+ends with::
+
+    import detectors
+    detectors.init(db)
+
+    return db
+
+and what we need to do is insert some commands that will set up the security
+parameters. Right above the ``import detectors`` line, you'll want to insert
+these lines::
+
+    #
+    # SECURITY SETTINGS
+    #
+    # new permissions for this schema
+    for cl in 'issue', 'file', 'msg', 'user':
+        db.security.addPermission(name="Edit", klass=cl,
+            description="User is allowed to edit "+cl)
+        db.security.addPermission(name="View", klass=cl,
+            description="User is allowed to access "+cl)
+
+    # Assign the access and edit permissions for issue, file and message
+    # to regular users now
+    for cl in 'issue', 'file', 'msg':
+        p = db.security.getPermission('View', cl)
+        db.security.addPermissionToRole('User', p)
+        p = db.security.getPermission('Edit', cl)
+        db.security.addPermissionToRole('User', p)
+    # and give the regular users access to the web and email interface
+    p = db.security.getPermission('Web Access')
+    db.security.addPermissionToRole('User', p)
+    p = db.security.getPermission('Email Access')
+    db.security.addPermissionToRole('User', p)
+
+    # May users view other user information? Comment these lines out
+    # if you don't want them to
+    p = db.security.getPermission('View', 'user')
+    db.security.addPermissionToRole('User', p)
+
+    # Assign the appropriate permissions to the anonymous user's Anonymous
+    # Role. Choices here are:
+    # - Allow anonymous users to register through the web
+    p = db.security.getPermission('Web Registration')
+    db.security.addPermissionToRole('Anonymous', p)
+    # - Allow anonymous (new) users to register through the email gateway
+    p = db.security.getPermission('Email Registration')
+    db.security.addPermissionToRole('Anonymous', p)
+    # - Allow anonymous users access to the "issue" class of data
+    #   Note: this also grants access to related information like files,
+    #         messages, statuses etc that are linked to issues
+    #p = db.security.getPermission('View', 'issue')
+    #db.security.addPermissionToRole('Anonymous', p)
+    # - Allow anonymous users access to edit the "issue" class of data
+    #   Note: this also grants access to create related information like
+    #         files and messages etc that are linked to issues
+    #p = db.security.getPermission('Edit', 'issue')
+    #db.security.addPermissionToRole('Anonymous', p)
+
+    # oh, g'wan, let anonymous access the web interface too
+    p = db.security.getPermission('Web Access')
+    db.security.addPermissionToRole('Anonymous', p)
+
+Note in the comments there the places where you might change the permissions
+to restrict users or grant users more access. If you've created additional
+classes that users should be able to edit and view, then you should add them
+to the "new permissions for this schema" section at the start of the security
+block. Then add them to the "Assign the access and edit permissions" section
+too, so people actually have the new Permission you've created.
+
+One final change is needed that finishes off the security system's
+initialisation. We need to add a call to ``db.post_init()`` at the end of the
+dbinit open() function. Add it like this::
+
+    import detectors
+    detectors.init(db)
+
+    # schema is set up - run any post-initialisation
+    db.post_init()
+    return db
+
+You may verify the setup of Permissions and Roles using the new
+"``roundup-admin security``" command.
+
+
+0.5.0 User changes
+~~~~~~~~~~~~~~~~~~
+
+To support all those schema changes, you'll need to massage your user database
+a little too, to:
+
+1. make sure there's an "anonymous" user - this user is mandatory now and is
+   the one that unknown users are logged in as.
+2. make sure all users have at least one Role.
+
+If you don't have the "anonymous" user, create it now with the command::
+
+  roundup-admin create user username=anonymous roles=Anonymous
+
+making sure the capitalisation is the same as above. Once you've done that,
+you'll need to set the roles property on all users to a reasonable default.
+The admin user should get "Admin", the anonymous user "Anonymous"
+and all other users "User". The ``fixroles.py`` script in the tools directory
+will do this. Run it like so (where python is your python 2+ binary)::
+
+  python tools/fixroles.py -i <tracker home> fixroles
+
+
+
+0.5.0 CGI interface changes
+---------------------------
+
+The CGI interface code was completely reorganised and largely rewritten. The
+end result is that this section of your tracker interfaces module will need
+changing from::
+
+ from roundup import cgi_client, mailgw
+ from roundup.i18n import _
+ 
+ class Client(cgi_client.Client):
+     ''' derives basic CGI implementation from the standard module,
+         with any specific extensions
+     '''
+     pass
+
+to::
+
+ from roundup import mailgw
+ from roundup.cgi import client
+ 
+ class Client(client.Client): 
+     ''' derives basic CGI implementation from the standard module,
+         with any specific extensions
+     '''
+     pass
+
+You will also need to install the new version of roundup.cgi from the source
+cgi-bin directory if you're using it.
+
+
+0.5.0 HTML templating
+---------------------
+
+You'll want to make a backup of your current tracker html directory. You
+should then copy the html directory from the Roundup source "classic" template
+and modify it according to your local schema changes.
+
+If you need help with the new templating system, please ask questions on the
+roundup-users mailing list (available through the roundup project page on
+sourceforge, http://roundup.sf.net/)
+
+
+0.5.0 Detectors
+---------------
+
+The nosy reactor has been updated to handle the tracker not having an
+"assignedto" property on issues. You may want to copy it into your tracker's
+detectors directory. Chances are you've already fixed it though :)
+
+
+Migrating from 0.4.1 to 0.4.2
+=============================
+
+0.4.2 Configuration
+-------------------
+The USER_INDEX definition introduced in 0.4.1 was too restrictive in its
+allowing replacement of 'assignedto' with the user's userid. Users must change
+the None value of 'assignedto' to 'CURRENT USER' (the string, in quotes) for
+the replacement behaviour to occur now.
+
+The new configuration variables are:
+
+- EMAIL_KEEP_QUOTED_TEXT 
+- EMAIL_LEAVE_BODY_UNCHANGED
+- ADD_RECIPIENTS_TO_NOSY
+
+See the sample configuration files in::
+
+ <roundup source>/roundup/templates/classic/instance_config.py
+
+and::
+
+ <roundup source>/roundup/templates/extended/instance_config.py
+
+and the `customisation documentation`_ for information on how they're used.
+
+
+0.4.2 Changes to detectors
+--------------------------
+You will need to copy the detectors from the distribution into your instance
+home "detectors" directory. If you used the classic schema, the detectors
+are in::
+
+ <roundup source>/roundup/templates/classic/detectors/
+
+If you used the extended schema, the detectors are in::
+
+ <roundup source>/roundup/templates/extended/detectors/
+
+The change means that schema-specific code has been removed from the
+mail gateway and cgi interface and made into auditors:
+
+- nosyreactor.py has now got an updatenosy auditor which updates the nosy
+  list with author, recipient and assignedto information.
+- statusauditor.py makes the unread or resolved -> chatting changes and
+  presets the status of an issue to unread.
+
+There's also a bug or two fixed in the nosyreactor code.
+
+0.4.2 HTML templating changes
+-----------------------------
+The link() htmltemplate function now has a "showid" option for links and
+multilinks. When true, it only displays the linked item id as the anchor
+text. The link value is displayed as a tooltip using the title anchor
+attribute. To use in eg. the superseder field, have something like this::
+
+   <td>
+    <display call="field('superseder', showid=1)">
+    <display call="classhelp('issue', 'id,title', label='list', width=500)">
+    <property name="superseder">
+     <br>View: <display call="link('superseder', showid=1)">
+    </property>
+   </td>
+
+The stylesheets have been cleaned up too. You may want to use the newer
+versions in::
+
+ <roundup source>/roundup/templates/<template>/html/default.css
+
+
+
+Migrating from 0.4.0 to 0.4.1
+=============================
+
+0.4.1 Files storage
+-------------------
+
+Messages and files from newly created issues will be put into subdierectories
+in thousands e.g. msg123 will be put into files/msg/0/msg123, file2003
+will go into files/file/2/file2003. Previous messages are still found, but
+could be put into this structure.
+
+0.4.1 Configuration
+-------------------
+
+To allow more fine-grained access control, the variable used to check
+permission to auto-register users in the mail gateway is now called
+ANONYMOUS_REGISTER_MAIL rather than overloading ANONYMOUS_REGISTER. If the
+variable doesn't exist, then ANONYMOUS_REGISTER is tested as before.
+
+Configuring the links in the web header is now easier too. The following
+variables have been added to the classic instance_config.py::
+
+  HEADER_INDEX_LINKS   - defines the "index" links to be made available
+  HEADER_ADD_LINKS     - defines the "add" links
+  DEFAULT_INDEX        - specifies the index view for DEFAULT
+  UNASSIGNED_INDEX     - specifies the index view for UNASSIGNED
+  USER_INDEX           - specifies the index view for USER
+
+See the <roundup source>/roundup/templates/classic/instance_config.py for more
+information - including how the variables are to be set up. Most users will
+just be able to copy the variables from the source to their instance home. If
+you've modified the header by changing the source of the interfaces.py file in
+the instance home, you'll need to remove that customisation and move it into
+the appropriate variables in instance_config.py.
+
+The extended schema has similar variables added too - see the source for more
+info.
+
+0.4.1 Alternate E-Mail Addresses
+--------------------------------
+
+If you add the property "alternate_addresses" to your user class, your users
+will be able to register alternate email addresses that they may use to
+communicate with roundup as. All email from roundup will continue to be sent
+to their primary address.
+
+If you have not edited the dbinit.py file in your instance home directory,
+you may simply copy the new dbinit.py file from the core code. If you used
+the classic schema, the interfaces file is in::
+
+ <roundup source>/roundup/templates/classic/dbinit.py
+
+If you used the extended schema, the file is in::
+
+ <roundup source>/roundup/templates/extended/dbinit.py 
+
+If you have modified your dbinit.py file, you need to edit the dbinit.py
+file in your instance home directory. Find the lines which define the user
+class::
+
+    user = Class(db, "msg",
+                    username=String(),   password=Password(),
+                    address=String(),    realname=String(), 
+                    phone=String(),      organisation=String(),
+                    alternate_addresses=String())
+
+You will also want to add the property to the user's details page. The
+template for this is the "user.item" file in your instance home "html"
+directory. Similar to above, you may copy the file from the roundup source if
+you haven't modified it. Otherwise, add the following to the template::
+
+   <display call="multiline('alternate_addresses')">
+
+with appropriate labelling etc. See the standard template for an idea.
+
+
+
+Migrating from 0.3.x to 0.4.0
+=============================
+
+0.4.0 Message-ID and In-Reply-To addition
+-----------------------------------------
+0.4.0 adds the tracking of messages by message-id and allows threading
+using in-reply-to. Most e-mail clients support threading using this
+feature, and we hope to add support for it to the web gateway. If you
+have not edited the dbinit.py file in your instance home directory, you may
+simply copy the new dbinit.py file from the core code. If you used the
+classic schema, the interfaces file is in::
+
+ <roundup source>/roundup/templates/classic/dbinit.py
+
+If you used the extended schema, the file is in::
+
+ <roundup source>/roundup/templates/extended/dbinit.py 
+
+If you have modified your dbinit.py file, you need to edit the dbinit.py
+file in your instance home directory. Find the lines which define the msg
+class::
+
+    msg = FileClass(db, "msg",
+                    author=Link("user"), recipients=Multilink("user"),
+                    date=Date(),         summary=String(),
+                    files=Multilink("file"))
+
+and add the messageid and inreplyto properties like so::
+
+    msg = FileClass(db, "msg",
+                    author=Link("user"), recipients=Multilink("user"),
+                    date=Date(),         summary=String(),
+                    files=Multilink("file"),
+                    messageid=String(),  inreplyto=String())
+
+Also, configuration is being cleaned up. This means that your dbinit.py will
+also need to be changed in the open function. If you haven't changed your
+dbinit.py, the above copy will be enough. If you have, you'll need to change
+the line (round line 50)::
+
+    db = Database(instance_config.DATABASE, name)
+
+to::
+
+    db = Database(instance_config, name)
+
+
+0.4.0 Configuration
+--------------------
+``TRACKER_NAME`` and ``EMAIL_SIGNATURE_POSITION`` have been added to the
+instance_config.py. The simplest solution is to copy the default values
+from template in the core source.
+
+The mail gateway now checks ``ANONYMOUS_REGISTER`` to see if unknown users
+are to be automatically registered with the tracker. If it is set to "deny"
+then unknown users will not have access. If it is set to "allow" they will be
+automatically registered with the tracker.
+
+
+0.4.0 CGI script roundup.cgi
+----------------------------
+The CGI script has been updated with some features and a bugfix, so you should
+copy it from the roundup cgi-bin source directory again. Make sure you update
+the ROUNDUP_INSTANCE_HOMES after the copy.
+
+
+0.4.0 Nosy reactor
+------------------
+The nosy reactor has also changed - copy the nosyreactor.py file from the core
+source::
+
+   <roundup source>/roundup/templates/<template>/detectors/nosyreactor.py
+
+to your instance home "detectors" directory.
+
+
+0.4.0 HTML templating
+---------------------
+The field() function was incorrectly implemented - links and multilinks now
+display as text fields when rendered using field(). To display a menu (drop-
+down or select box) you need to use the menu() function.
+
+
+
+Migrating from 0.2.x to 0.3.x
+=============================
+
+0.3.x Cookie Authentication changes
+-----------------------------------
+0.3.0 introduces cookie authentication - you will need to copy the
+interfaces.py file from the roundup source to your instance home to enable
+authentication. If you used the classic schema, the interfaces file is in::
+
+ <roundup source>/roundup/templates/classic/interfaces.py
+
+If you used the extended schema, the file is in::
+
+ <roundup source>/roundup/templates/extended/interfaces.py
+
+If you have modified your interfaces.Client class, you will need to take
+note of the login/logout functionality provided in roundup.cgi_client.Client
+(classic schema) or roundup.cgi_client.ExtendedClient (extended schema) and
+modify your instance code apropriately.
+
+
+0.3.x Password encoding
+-----------------------
+This release also introduces encoding of passwords in the database. If you
+have not edited the dbinit.py file in your instance home directory, you may
+simply copy the new dbinit.py file from the core code. If you used the
+classic schema, the interfaces file is in::
+
+ <roundup source>/roundup/templates/classic/dbinit.py
+
+If you used the extended schema, the file is in::
+
+ <roundup source>/roundup/templates/extended/dbinit.py
+
+
+If you have modified your dbinit.py file, you may use encoded passwords:
+
+1. Edit the dbinit.py file in your instance home directory
+   a. At the first code line of the open() function::
+
+       from roundup.hyperdb import String, Date, Link, Multilink
+
+      alter to include Password, as so::
+
+       from roundup.hyperdb import String, Password, Date, Link, Multilink
+
+   b. Where the password property is defined (around line 66)::
+
+       user = Class(db, "user", 
+                       username=String(),   password=String(),
+                       address=String(),    realname=String(), 
+                       phone=String(),      organisation=String())
+       user.setkey("username")
+
+      alter the "password=String()" to "password=Password()"::
+
+       user = Class(db, "user", 
+                       username=String(),   password=Password(),
+                       address=String(),    realname=String(), 
+                       phone=String(),      organisation=String())
+       user.setkey("username")
+
+2. Any existing passwords in the database will remain cleartext until they
+   are edited. It is recommended that at a minimum the admin password be
+   changed immediately::
+
+      roundup-admin -i <instance home> set user1 password=<new password>
+
+
+0.3.x Configuration
+-------------------
+FILTER_POSITION, ANONYMOUS_ACCESS, ANONYMOUS_REGISTER have been added to
+the instance_config.py. Simplest solution is to copy the default values from
+template in the core source.
+
+MESSAGES_TO_AUTHOR has been added to the IssueClass in dbinit.py. Set to 'yes'
+to send nosy messages to the author. Default behaviour is to not send nosy
+messages to the author. You will need to add MESSAGES_TO_AUTHOR to your
+dbinit.py in your instance home.
+
+
+0.3.x CGI script roundup.cgi
+----------------------------
+There have been some structural changes to the roundup.cgi script - you will
+need to install it again from the cgi-bin directory of the source
+distribution. Make sure you update the ROUNDUP_INSTANCE_HOMES after the
+copy.
+
+
+.. _`customisation documentation`: customizing.html
+.. _`security documentation`: security.html
+.. _`administration guide`: admin_guide.html

Added: tracker/vendor/roundup/current/doc/user_guide.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/user_guide.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,787 @@
+==========
+User Guide
+==========
+
+:Version: $Revision: 1.32 $
+
+.. contents::
+
+.. hint::
+   This document will refer to *issues* as the primary store of
+   information in the tracker. This is the default of the classic template,
+   but may vary in any given installation.
+
+
+Your Tracker in a Nutshell
+==========================
+
+Your tracker holds information about issues in bundles we call *items*.
+An item may be an *issue* (a bug or feature request) or a *user*. The
+issue-ness or user-ness is called the item's *class*. So, for bug
+reports and features, the class is "issue", and for users the class is
+"user".
+
+Each item in the tracker has an id number that identifies it along with
+its item class. To identify a particular issue or user, we combine the
+class with the number to create a unique label, so that user 1 (who,
+incidentally, is *always* the "admin" user) is referred to as "user1".
+Issue number 315 is referred to as "issue315". We call that label the
+item's *designator*.
+
+Items in the database are never deleted, they're just "retired". You
+can still refer to them by ID - hence removing an item won't break
+references to the item. It's just that the item won't appear in any
+listings.
+
+
+Accessing the Tracker
+---------------------
+
+You may access your tracker through one of three ways:
+
+1. through the `web interface`_,
+2. through the `e-mail gateway`_, or
+3. using the `command line tool`_.
+
+The last is usually only used by administrators. Most users will use the
+web and e-mail interfaces. All three are explained below.
+
+
+Issue life cycles in Roundup
+----------------------------
+
+New issues may be submitted via the web or e-mail.
+
+By default, the issue will have the status "unread". If another message
+is received for the issue, its status will change to "chatting". 
+
+The "home" page for a tracker will generally display all issues which
+are not "resolved".
+
+If an issue is closed, and a new message is received then it'll be
+reopened to the state of "chatting".
+
+
+Entering values in your Tracker
+-------------------------------
+
+All interfaces to your tracker use the same format for entering values.
+This means the web interface for entering a new issue, the web interface
+for searching issues, the e-mail interface and even the command-line
+administration tool.
+
+
+String and Numeric properties
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+These fields just take a simple text value, like ``It's broken``.
+
+
+Boolean properties
+~~~~~~~~~~~~~~~~~~
+
+These fields take a value which indicates "yes"/"no", "true"/"false",
+"1"/"0" or "on"/"off".
+
+
+Constrained (link and multilink) properties
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Fields like "Assigned To" and "Topics" hold references to items in other
+classes ("user" and "keyword" in those two cases.)
+
+Sometimes, the selection is done through a menu, like in the "Assigned
+To" field.
+
+Where the input is not a simple menu selection, we use a comma-separated
+list of values to indicated which values of "user" or "keyword" are
+interesting. The values may be either numeric ids or the names of items.
+The special value "-1" may be used to match items where the property is
+not set. For example, the following searches on the issues:
+
+``assignedto=richard,george``
+  match issues which are assigned to richard or george.
+``assignedto=-1``
+  match issues that are not assigned to a user.
+``assignedto=2,3,40``
+  match issues that are assigned to users 2, 3 or 40.
+``topic=user interface``
+  match issues with the keyword "user interface" in their topic list
+``topic=web interface,e-mail interface``
+  match issues with the keyword "web interface" or "e-mail interface" in
+  their topic list
+``topic=-1``
+  match issues with no topics set
+
+
+Date properties
+~~~~~~~~~~~~~~~
+
+Date-and-time stamps are specified with the date in
+international standard format (``yyyy-mm-dd``) joined to the time
+(``hh:mm:ss``) by a period ``.``.  Dates in this form can be easily
+compared and are fairly readable when printed.  An example of a valid
+stamp is ``2000-06-24.13:03:59``. We'll call this the "full date
+format".  When Timestamp objects are printed as strings, they appear in
+the full date format.
+
+For user input, some partial forms are also permitted: the whole time or
+just the seconds may be omitted; and the whole date may be omitted or
+just the year may be omitted.  If the time is given, the time is
+interpreted in the user's local time zone. The Date constructor takes
+care of these conversions. In the following examples, suppose that
+``yyyy`` is the current year, ``mm`` is the current month, and ``dd`` is
+the current day of the month.
+
+-   "2000-04-17" means <Date 2000-04-17.00:00:00>
+-   "01-25" means <Date yyyy-01-25.00:00:00>
+-   "2000-04-17.03:45" means <Date 2000-04-17.08:45:00>
+-   "08-13.22:13" means <Date yyyy-08-14.03:13:00>
+-   "11-07.09:32:43" means <Date yyyy-11-07.14:32:43>
+-   "14:25" means
+-   <Date yyyy-mm-dd.19:25:00>
+-   "8:47:11" means
+-   <Date yyyy-mm-dd.13:47:11>
+-   the special date "." means "right now"
+
+
+When searching, a plain date entered as a search field will match that date
+exactly in the database.  We may also accept ranges of dates. You can
+specify range of dates in one of two formats:
+
+1. English syntax::
+
+    [From <value>][To <value>]
+
+   Keywords "From" and "To" are case insensitive. Keyword "From" is
+   optional.
+
+2. "Geek" syntax::
+
+    [<value>];[<value>]
+
+Either first or second ``<value>`` can be omitted in both syntaxes.
+
+For example, if you enter string "from 9:00" to "Creation date" field,
+roundup will find  all issues, that were created today since 9 AM.
+
+The ``<value>`` may also be an interval, as described in the next section.
+Searching of "-2m; -1m" on activity field gives you issues which were
+active between period of time since 2 months up-till month ago.
+
+Other possible examples (consider local time is 2003-03-08.22:07:48):
+
+- "from 2-12 to 4-2" means
+  <Range from 2003-02-12.00:00:00 to 2003-04-02.00:00:00>
+- "FROM 18:00 TO +2m" means
+  <Range from 2003-03-08.18:00:00 to 2003-05-08.20:07:48>
+- "12:00;" means
+  <Range from 2003-03-08.12:00:00 to None>
+- "tO +3d" means
+  <Range from None to 2003-03-11.20:07:48>
+- "2002-11-10; 2002-12-12" means
+  <Range from 2002-11-10.00:00:00 to 2002-12-12.00:00:00>
+- "; 20:00 +1d" means
+  <Range from None to 2003-03-09.20:00:00>
+- "2003" means
+  <Range from 2003-01-01.00:00:00 to 2003-12-31.23:59:59>
+- "2003-04" means
+  <Range from 2003-04-01.00:00:00 to 2003-04-30.23:59:59>
+    
+
+Interval properties
+~~~~~~~~~~~~~~~~~~~
+
+Date intervals are specified using the suffixes "y", "m", and "d".  The
+suffix "w" (for "week") means 7 days. Time intervals are specified in
+hh:mm:ss format (the seconds may be omitted, but the hours and minutes
+may not).
+
+-   "3y" means three years
+-   "2y 1m" means two years and one month
+-   "1m 25d" means one month and 25 days
+-   "2w 3d" means two weeks and three days
+-   "1d 2:50" means one day, two hours, and 50 minutes
+-   "14:00" means 14 hours
+-   "0:04:33" means four minutes and 33 seconds
+
+
+Simple support for collision detection
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Item edit pages remember when the item was last edited. When a form is
+submitted, the user will be informed if someone else has edited the item
+at the same time they tried to.
+
+
+Web Interface
+=============
+
+.. note::
+   This document contains screenshots of the default look and feel.
+   Your site may have a slightly (or very) different look, but the
+   functionality will be very similar, and the concepts still hold.
+
+The web interface is broken up into the following parts:
+
+1. `lists of items`_,
+2. `display, edit or entry of an item`_, and
+3. `searching page`_.
+
+
+Lists of Items
+--------------
+
+The first thing you'll see when you log into Roundup will be a list of
+open (ie. not resolved) issues. This list has been generated by a bunch
+of controls `under the covers`_ but for now, you can see something like:
+
+.. img: images/index_logged_out.png
+
+The screen is divided up into three sections:
+
+.. img: images/page_layout.png
+
+you may either register or log in. Registration takes you to:
+
+.. img: images/registration.png
+
+Once you're logged in, the screen changes slightly to:
+
+.. img: images/index_logged_in.png
+
+Note that the sidebar menu has changed slightly, so you can now get to
+your "My Details" page:
+
+.. img: images/my_details.png
+
+Note the new information on this page - the history.
+
+
+Display, edit or entry of an item
+---------------------------------
+
+Create a new issue with "create new" under the issue subheading. This
+will take you to:
+
+.. img: images/new_issue.png
+
+The `nosy list`_ is explained below. Enter some information and click
+"submit new entry" and you'll be rewarded with:
+
+.. img: images/new_issue_created.png
+
+or, if you don't enter all the required information (or some other error
+occurs) you'll get something like:
+
+.. img: images/new_issue_error.png
+
+
+Searching Page
+--------------
+
+See `entering values in your tracker`_ for an explanation of what you
+may type into the search form.
+
+
+Saving queries
+~~~~~~~~~~~~~~
+
+You may save queries in the tracker by giving the query a name. Each user
+may only have one query with a given name - if a subsequent search is
+performed with the same query name supplied, then it will edit the
+existing query of the same name.
+
+Queries may be marked as "private". These queries are only visible to the
+user that created them. If they're not marked "private" then all other
+users may include the query in their list of "Your Queries". Marking it as
+private at a later date does not affect users already using the query, nor
+does deleting the query.
+
+If a user subsequently creates or edits a public query, a new personal
+version of that query is made, with the same editing rules as described
+above.
+
+
+Under the covers
+~~~~~~~~~~~~~~~~
+
+The searching page converts your selections into the following
+arguments:
+
+============ =============================================================
+Argument     Description
+============ =============================================================
+ at sort        sort by prop name, optionally preceeded with '-' to give
+             descending or nothing for ascending sorting.
+ at group       group by prop name, optionally preceeded with '-' or to sort
+             in descending or nothing for ascending order.
+ at columns     selects the columns that should be displayed. Default is
+             all.                     
+ at filter      indicates which properties are being used in filtering.
+             Default is none.
+propname     selects the values the item properties given by propname must
+             have (very basic search/filter).
+ at search_text performs a full-text search (message bodies, issue titles,
+             etc)
+============ =============================================================
+
+You may manually write URLS that contain these arguments, like so
+(whitespace has been added for clarity)::
+
+    /issue?status=unread,in-progress,resolved&
+        topic=security,ui&
+        @group=priority&
+        @sort=-activity&
+        @filters=status,topic&
+        @columns=title,status,fixer
+
+
+Access Controls
+---------------
+
+User access is controlled through Permissions. These are are grouped
+into Roles, and users have a comma-separated list of Roles assigned to
+them.
+
+Permissions divide access controls up into answering questions like:
+
+- may the user edit issues ("Edit", "issue")
+- is the user allowed to use the web interface ("Web Access")
+- may the user edit other user's Roles through the web ("Web Roles")
+
+Any number of new Permissions and Roles may be created as described in
+the customisation documentation. Examples of new access controls are:
+
+- only managers may sign off issues as complete
+- don't give users who register through e-mail web access
+- let some users edit the details of all users
+
+
+E-Mail Gateway
+==============
+
+Roundup trackers may be used to facilitate e-mail conversations around
+issues. The "nosy" list attached to each issue indicates the users who
+should receive e-mail when messages are added to the issue.
+
+When e-mail comes into a tracker that identifies an issue in the subject
+line, the content of the e-mail is attached to the issue.
+
+You may even create new issues from e-mail messages.
+
+E-mail sent to a tracker is examined for several pieces of information:
+
+1. `subject-line information`_ identifying the purpose of the e-mail
+2. `sender identification`_ using the sender of the message
+3. `e-mail message content`_ which is to be extracted
+4. e-mail attachments which should be associated with the message
+
+
+Subject-line information
+------------------------
+
+The subject line of the incoming message is examined to find one of:
+
+1. the item that the message is responding to,
+2. the type of item the message should create, or
+3. we default the item class and try some trickiness
+
+If the subject line contains a prefix in ``[square brackets]`` then
+we're looking at case 1 or 2 above. Any "re:" or "fwd:" prefixes are
+stripped off the subject line before we start looking for real
+information.
+
+If an item designator (class name and id number, for example
+``issue123``) is found there, a new "msg" item is added to the
+"messages" property for that item, and any new "file" items are added to
+the "files" property for the item.
+
+If just an item class name is found there, we attempt to create a new
+item of that class with its "messages" property initialized to contain
+the new "msg" item and its "files" property initialized to contain any
+new "file" items.
+
+The third case above - where no ``[information]`` is provided, the
+tracker's ``MAIL_DEFAULT_CLASS`` configuration variable defines what
+class of item the message relates to. We try to match the subject line
+to an existing item of the default class, and if there's a match, the
+message is related to that matched item. If not, then a new item of the
+default class is created.
+
+
+Setting Properties
+~~~~~~~~~~~~~~~~~~
+
+The e-mail interface also provides a simple way to set properties on
+items. At the end of the subject line, propname=value pairs can be
+specified in square brackets, using the same conventions as for the
+roundup set shell command.
+
+For example,
+
+- setting the priority of an issue::
+
+   Subject: Re: [issue1] the coffee machine is broken! [priority=urgent]
+
+- adding yourself to a nosy list::
+
+   Subject: Re: [issue2] we're out of widgets [nosy=+richard]
+
+- setting the nosy list to just you and cliff::
+
+   Subject: Re: [issue2] we're out of widgets [nosy=richard,cliff]
+
+- removing yourself from a nosy list and setting the priority::
+
+   Subject: Re: [issue2] we're out of widgets [nosy=-richard;priority=bug]
+
+In all cases, the message relates to issue 2. The ``Re:`` prefix is
+stripped off.
+
+
+Automatic Properties
+~~~~~~~~~~~~~~~~~~~~
+
+**status of new issues**
+ When a new message is received that is not identified as being related
+ to an existing issue, it creates a new issue. The status of the new
+ issue is defaulted to "unread".
+
+**reopening of resolved issues**
+ When a message is is received for a resolved issue, the issue status is
+ automatically reset to "chatting" to indicate new information has been
+ received.
+
+
+Sender identification
+---------------------
+
+If the sender of an e-mail is unknown to Roundup (looking up both user
+primary e-mail addresses and their alternate addresses) then a new user
+will be created. The new user will have their username set to the "user"
+part of "user at domain" in their e-mail address. Their password will be
+completely randomised, and they'll have to visit the web interface to
+have it changed. Some sites don't allow web access by users who register
+via e-mail like this.
+
+
+E-Mail Message Content
+----------------------
+
+Roundup only associates plain text (MIME type ``text/plain``) as
+messages for items. Any other parts of a message are associated as
+downloadable files. If no plain text part is found, the message is
+rejected.
+
+To do this, incoming messages are examined for multiple parts:
+
+* In a multipart/mixed message or part, each subpart is extracted and
+  examined. The text/plain subparts are assembled to form the textual
+  body of the message, to be stored in the file associated with a "msg"
+  class item. Any parts of other types are each stored in separate files
+  and given "file" class items that are linked to the "msg" item.
+* In a multipart/alternative message or part, we look for a text/plain
+  subpart and ignore the other parts.
+
+If the message is a response to a previous message, and contains quoted
+sections, then these will be stripped out of the message if the
+``EMAIL_KEEP_QUOTED_TEXT`` configuration variable is set to ``'no'``.
+
+Message summary
+~~~~~~~~~~~~~~~
+
+The "summary" property on message items is taken from the first
+non-quoting section in the message body. The message body is divided
+into sections by blank lines. Sections where the second and all
+subsequent lines begin with a ">" or "|" character are considered
+"quoting sections". The first line of the first non-quoting section
+becomes the summary of the message.
+
+
+Address handling
+----------------
+
+All of the addresses in the ``To:`` and ``Cc:`` headers of the incoming
+message are looked up among the tracker users, and the corresponding
+users are placed in the "recipients" property on the new "msg" item. The
+address in the ``From:`` header similarly determines the "author"
+property of the new "msg" item. The default handling for addresses that
+don't have corresponding users is to create new users with no passwords
+and a username equal to the address.
+
+The addresses mentioned in the ``To:``, ``From:`` and ``Cc:`` headers of
+the message may be added to the `nosy list`_ depending on:
+
+``ADD_AUTHOR_TO_NOSY``
+ Does the author of a message get placed on the nosy list automatically?
+ If 'new' is used, then the author will only be added when a message
+ creates a new issue. If 'yes', then the author will be added on
+ followups too. If 'no', they're never added to the nosy.
+
+``ADD_RECIPIENTS_TO_NOSY``
+ Do the recipients (To:, Cc:) of a message get placed on the nosy list?
+ If 'new' is used, then the recipients will only be added when a message
+ creates a new issue. If 'yes', then the recipients will be added on
+ followups too. If 'no', they're never added to the nosy.
+
+
+Nosy List
+~~~~~~~~~
+
+Roundup watches for additions to the "messages" property of items.
+
+When a new message is added, it is sent to all the users on the "nosy"
+list for the item that are not already on the "recipients" list of the
+message. Those users are then appended to the "recipients" property on
+the message, so multiple copies of a message are never sent to the same
+user. The journal recorded by the hyperdatabase on the "recipients"
+property then provides a log of when the message was sent to whom.
+
+If the author of the message is also in the nosy list for the item that
+the message is attached to, then the config var ``MESSAGES_TO_AUTHOR``
+is queried to determine if they get a nosy list copy of the message too.
+
+
+Mail gateway script command line
+--------------------------------
+
+Usage::
+
+  roundup-mailgw [[-C class] -S field=value]* <instance home> [method]
+
+The roundup mail gateway may be called in one of three ways:
+
+ - with an instance home as the only argument,
+ - with both an instance home and a mail spool file, or
+ - with both an instance home and a pop server account.
+ 
+It also supports optional -C and -S arguments that allows you to set a
+fields for a class created by the roundup-mailgw. The default class if
+not specified is msg, but the other classes: issue, file, user can also
+be used. The -S or --set options uses the same
+property=value[;property=value] notation accepted by the command line
+roundup command or the commands that can be given on the Subject line of
+an e-mail message.
+
+It can let you set the type of the message on a per e-mail address basis.
+
+PIPE:
+ In the first case, the mail gateway reads a single message from the
+ standard input and submits the message to the roundup.mailgw module.
+
+UNIX mailbox:
+ In the second case, the gateway reads all messages from the mail spool
+ file and submits each in turn to the roundup.mailgw module. The file is
+ emptied once all messages have been successfully handled. The file is
+ specified as::
+
+   mailbox /path/to/mailbox
+
+POP:
+ In the third case, the gateway reads all messages from the POP server
+ specified and submits each in turn to the roundup.mailgw module. The
+ server is specified as::
+
+    pop username:password at server
+
+ The username and password may be omitted::
+
+    pop username at server
+    pop server
+
+ are both valid. The username and/or password will be prompted for if
+ not supplied on the command-line.
+
+APOP:
+ Same as POP, but using Authenticated POP::
+
+    apop username:password at server
+
+
+Command Line Tool
+=================
+
+The basic usage is::
+
+ Usage: roundup-admin [options] [<command> <arguments>]
+
+ Options:
+  -i instance home  -- specify the issue tracker "home directory" to administer
+  -u                -- the user[:password] to use for commands
+  -d                -- print full designators not just class id numbers
+  -c                -- when outputting lists of data, comma-separate them.
+               Same as '-S ","'.
+  -S <string>       -- when outputting lists of data, string-separate them
+  -s                -- when outputting lists of data, space-separate them.
+               Same as '-S " "'.
+
+  Only one of -s, -c or -S can be specified.
+
+ Help:
+  roundup-admin -h
+  roundup-admin help                       -- this help
+  roundup-admin help <command>             -- command-specific help
+  roundup-admin help all                   -- all available help
+
+ Commands: 
+  commit
+  create classname property=value ...
+  display designator[,designator]*
+  export [class[,class]] export_dir
+  find classname propname=value ...
+  get property designator[,designator]*
+  help topic
+  history designator
+  import import_dir
+  initialise [adminpw]
+  install [template [backend [admin password]]]
+  list classname [property]
+  pack period | date
+  reindex
+  retire designator[,designator]*
+  rollback
+  security [Role name]
+  set items property=value property=value ...
+  specification classname
+  table classname [property[,property]*]
+ Commands may be abbreviated as long as the abbreviation matches only one
+ command, e.g. l == li == lis == list.
+
+
+All commands (except help) require a tracker specifier. This is just the
+path to the roundup tracker you're working with. A roundup tracker is
+where roundup keeps the database and configuration file that defines an
+issue tracker. It may be thought of as the issue tracker's "home
+directory". It may be specified in the environment variable
+``TRACKER_HOME`` or on the command line as "``-i tracker``".
+
+A designator is a classname and an itemid concatenated, eg. bug1,
+user10, ... Property values are represented as strings in command
+arguments and in the printed results:
+
+- Strings are, well, strings.
+- Password values will display as their encoded value.
+- Date values are printed in the full date format in the local time
+  zone, and accepted in the full format or any of the partial formats
+  explained below.::
+  
+    Input of...        Means...
+    "2000-04-17.03:45" 2000-04-17.03:45:00
+    "2000-04-17"       2000-04-17.00:00:00
+    "01-25"            yyyy-01-25.00:00:00
+    "08-13.22:13"      yyyy-08-13.22:13:00
+    "11-07.09:32:43"   yyyy-11-07.09:32:43
+    "14:25"            yyyy-mm-dd.14:25:00
+    "8:47:11"          yyyy-mm-dd.08:47:11
+    "2003"             2003-01-01.00:00:00
+    "2003-04"          2003-04-01.00:00:00
+    "."                "right now"
+    
+- Link values are printed as item designators. When given as an
+  argument, item designators and key strings are both accepted.
+- Multilink values are printed as lists of item designators joined by
+  commas. When given as an argument, item designators and key strings
+  are both accepted; an empty string, a single item, or a list of items
+  joined by commas is accepted.
+  
+When multiple items are specified to the roundup get or roundup set
+commands, the specified properties are retrieved or set on all the
+listed items.  When multiple results are returned by the roundup get or
+roundup find commands, they are printed one per line (default) or joined
+by commas (with the "``-c``" option).
+
+Where the command changes data, a login name/password is required. The
+login may be specified as either "``name``" or "``name:password``".
+
+- ``ROUNDUP_LOGIN`` environment variable
+- the "``-u``" command-line option
+
+If either the name or password is not supplied, they are obtained from
+the command-line.
+
+
+Using with the shell
+--------------------
+
+With version 0.6.0 or newer of roundup which supports: multiple
+designators to display and the -d, -S and -s flags.
+
+To find all messages regarding chatting issues that contain the word
+"spam", for example, you could execute the following command from the
+directory where the database dumps its files::
+
+    shell% for issue in `roundup-admin -ds find issue status=chatting`; do
+    > grep -l spam `roundup-admin -ds ' ' get messages $issue`
+    > done
+    msg23
+    msg49
+    msg50
+    msg61
+    shell%
+
+Or, using the -dc option, this can be written as a single command::
+
+    shell% grep -l spam `roundup get messages \
+        \`roundup -dc find issue status=chatting\``
+    msg23
+    msg49
+    msg50
+    msg61
+    shell%
+
+You can also display issue contents::
+
+    shell% roundup-admin display `roundup-admin -dc get messages \
+               issue3,issue1`
+    files: []
+    inreplyto: None
+    recipients: []
+    author: 1
+    date: 2003-02-16.21:23:03
+    messageid: None
+    summary: jkdskldjf
+    files: []
+    inreplyto: None
+    recipients: []
+    author: 1
+    date: 2003-02-15.01:59:11
+    messageid: None
+    summary: jlkfjadsf    
+    
+or status::
+
+    shell% roundup-admin get name `/tools/roundup/bin/roundup-admin \
+          -dc -i /var/roundup/sysadmin get status issue3,issue1`
+    unread
+    deferred
+
+or status on a single line::
+
+    shell% echo `roundup-admin get name \`/tools/roundup/bin/roundup-admin \
+             -dc -i /var/roundup/sysadmin get status issue3,issue1\``
+    unread deferred
+
+which is the same as::
+
+    shell% roundup-admin -s get name `/tools/roundup/bin/roundup-admin \
+             -dc -i /var/roundup/sysadmin get status issue3,issue1`
+    unread deferred
+
+Also the tautological::
+
+   shell% roundup-admin get name \
+      `roundup-admin -dc get status \`roundup-admin -dc find issue \
+          status=chatting\``
+   chatting
+   chatting
+
+Remember the roundup commands that accept multiple designators accept
+them ',' separated so using '-dc' is almost always required.
+
+-----------------
+
+Back to `Table of Contents`_
+
+.. _`Table of Contents`: index.html
+.. _`customisation documentation`: customizing.html

Added: tracker/vendor/roundup/current/doc/whatsnew-0.7.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/whatsnew-0.7.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,481 @@
+=========================
+What's New in Roundup 0.7
+=========================
+
+For those completely new to Roundup, you might want to look over the very
+terse features__ page.
+
+__ features.html
+
+.. contents::
+
+Instant-Gratification script even more gratifying
+=================================================
+
+The immensely popular ``python demo.py`` instant-gratification script has
+been extended to allow you to choose the backend to use with the demo. To
+select the "sqlite" backend (assuming it is available) you use::
+
+  python demo.py sqlite nuke
+
+This will nuke any existing demo and reinitialise it with the sqlite
+backend. Remember folks, if you want to restart the demo at a later point,
+you just need to type::
+
+  python demo.py
+
+without the "sqlite nuke" part, or you'll clear out the demo again. The
+backend names are:
+
+  anydbm bsddb bsddb3 sqlite metakit mysql postgresql
+
+You will need support modules installed for all except the first two. If
+you're not sure whether you have support, run::
+
+  python run_tests.py
+
+and if you see a line saying "Including XXXX tests" where XXXX is the
+backend you wish to try, then you're on your way. The mysql and postgresql
+require their test environments to be set up. Read their respective
+documents in the "doc" directory to do that.
+
+
+Web Interface
+=============
+
+Saving and sharing of user queries
+----------------------------------
+
+Due to popular demand, the user query saving mechanisms have been
+overhauled.
+
+As before, you may save queries in the tracker by giving the query a
+name. Each user may only have one query with a given name - if a
+subsequent search is performed with the same query name supplied, then
+it will edit the existing query of the same name.
+
+Queries may be marked as "private". These queries are only visible to the
+user that created them. If they're not marked "private" then all other
+users may include the query in their list of "Your Queries". Marking it as
+private at a later date does not affect users already using the query, nor
+does deleting the query.
+
+If a user subsequently creates or edits a public query, a new personal
+version of that query is made, with the same editing rules as described
+above.
+
+You *are not required* to make these changes in your tracker. You only
+need to make them if you wish to use the new query editing features. It's
+highly recommended, as the effort is minimal.
+
+1. You will need to edit your tracker's ``dbinit.py`` to change the way
+   queries are stored. Change the lines::
+
+      query = Class(db, "query",
+                      klass=String(),     name=String(),
+                      url=String())
+      query.setkey("name")
+
+   to::
+
+      query = Class(db, "query",
+                      klass=String(),     name=String(),
+                      url=String(),       private_for=Link('user'))
+
+   That is, add the "private_for" property, and remove the line that says
+   ``query.setkey("name")``.
+
+2. You will also need to copy the ``query.edit.html`` template page from the
+   ``templates/classic/html/`` directory of the source to your tracker's
+   ``html`` directory.
+
+3. Once you've done that, edit the tracker's ``page.html`` template to
+   change::
+
+    <td rowspan="2" valign="top" class="sidebar">
+     <p class="classblock" tal:condition="request/user/queries">
+      <b>Your Queries</b><br>
+      <tal:block tal:repeat="qs request/user/queries">
+
+   to::
+
+    <td rowspan="2" valign="top" class="sidebar">
+     <p class="classblock">
+      <b>Your Queries</b> (<a href="query?@template=edit">edit</a>)<br>
+      <tal:block tal:repeat="qs request/user/queries">
+
+   That is, you're removing the ``tal:condition`` and adding a link to the
+   new edit page.
+
+4. You might also wish to remove the redundant query editing section from the
+   ``user.item.html`` page.
+
+ZRoundup reinstated
+-------------------
+
+The Zope interface, ZRoundup, lives again!
+
+See the `upgrading documentation`__ if you wish to use it.
+
+__ upgrading.html#zroundup-changes
+
+
+Simple support for collision detection
+--------------------------------------
+
+Item edit pages that use the ``context/submit`` function to generate their
+submit buttons now automatically include a datestamp in the form. This
+datestamp is compared to the "activity" property of the item when the form
+is submitted. If the "actvity" property is younger than the datestamp in
+the form submission, then someone else has edited the item, and a page
+indicating this is displayed to the user.
+
+
+Extending the cgi interface
+---------------------------
+
+Before 0.7.0 adding or extending web actions was done by overriding or adding
+methods on the Client class. Though this approach still works to provide
+backwards compatibility, it is recommended you upgrade to the new approach, as
+described in the `Defining new web actions`__ section of the customization
+documentation. You might also want to take a look at the `Using an external
+password validation source`__ example.
+
+__ customizing.html#defining-new-web-actions
+__ customizing.html#using-an-external-password-validation-source
+
+Actions may also return the content that should return to the user, which
+causes the web interface to skip the normal template formatting step.
+This could be used to return an image to the user instead of HTML. Be sure
+to set the correct content-type header though! The default is still
+text/html. This is done with::
+
+   self.client.setHeader('Content-Type', 'image/png')
+
+if you were returning a PNG image.
+
+
+Roundup server 
+--------------
+
+The roundup-server web interface now supports setgid and running on port
+< 1024.
+
+It also forks to handle new connections, which means that trackers using
+the postgresql or mysql backends will be able to have multiple users
+accessing the tracker simultaneously.
+
+
+HTML templating made easier
+---------------------------
+
+All HTML templating functions perform checks for permissions required to
+display or edit the data they are manipulating. The simplest case is
+editing an issue title. Including the expression::
+
+   context/title/field
+
+will present the user with an edit field if they have Edit Permission. If
+not, then they will be presented with a static display if they have View
+Permission. If they don't even have View Permission, then an error message
+is raised, preventing the display of the page, indicating that they don't
+have permission to view the information.
+
+This removes the need for the template to perform those checks, which was
+just plain messy.
+
+Some new permissions will need to be created in your trackers to cope with
+this change, as outlined in the `upgrading documentation`__.
+
+__ upgrading.html#permission-assignments
+
+
+Standards changes
+-----------------
+
+The HTTP Content-Length header when we serve up files, either
+static ones from the "html" folder or file content from the database.
+
+We also handle If-Modified-Since and supply Last-Modified for both types
+of file too.
+
+The HTML generated in the classic tracker is now HTML4 (or optionally
+XHTML) compliant. The ``config.py`` variable "HTML_VERSION" is used to
+control this behaviour.
+
+The stylesheet includes printer settings now too, so printed pages
+don't include the sidebar.
+
+
+Quoting of URLs and HTML
+------------------------
+
+Templates that wish to offer file downloads may now use a new
+``download_url`` method::
+
+ <tr tal:repeat="file context/files">
+  <td>
+   <a tal:attributes="href file/download_url"
+      tal:content="file/name">dld link</a>
+  </td>
+ ...
+
+The ``download_url`` method looks up the file's "id" and "name" and
+generates a correctly-quoted URL.
+
+Additionally, users wishing to URL- or HTML- quote text in their templates
+may use the new ``utils.url_quote(url)`` and ``utils.html_quote(html)``
+methods.
+
+
+CSV download of search results
+------------------------------
+
+A new CGI action, ``export_csv`` has been added which exports a given
+index page query as a comma-separated-value file.
+
+To use this new action, just add a link to your ``issue.index.html``
+page::
+
+  <a tal:attributes="href python:request.indexargs_url('issue',
+            {'@action':'export_csv'})">Download as CSV</a>
+
+You may use this for other classes by adding it to their index page and
+changing the ``'issue'`` part of the expression to the new class' name.
+
+
+Other changes
+-------------
+
+- we serve up a favicon now
+- the page titles have the tracker name at the end of the text instead
+  of the start
+- added url_quote and html_quote methods to the utils object
+- added isset method to HTMLProperty
+- added search_checkboxes as an option for the search form
+
+
+Email Interface
+===============
+
+Better handling of some email headers
+-------------------------------------
+
+We ignore messages with the header "Precedence: bulk".
+
+If a Resent-From: header is present, it is used in preference to the From:
+header when determining the author of the message. Useful for redirecting
+error messages from automated systems.
+
+
+Email character set
+-------------------
+
+The default character set for sending email is UTF-8 (ie. Unicode). If you
+have users whose email clients can't handle UTF-8 (eg. Eudora) then you
+will need to edit the new config.py variable ``EMAIL_CHARSET``.
+
+
+Dispatcher configuration
+------------------------
+
+A new config option has been added that specifies the email address of
+a "dispatcher" role.  This email address acts as a central sentinel for
+issues coming into the system. You can configure it so that all e-mail
+error messages get bounced to them, them and the user in question, or
+just the user (default).
+
+To toggle these switches, add the "DISPATCHER_EMAIL" and
+"ERROR_MESSAGES_TO" configuration values to your tracker's ``config.py``.
+See the `customisation documentation`_ for how to use them.
+
+
+More flexible message generation
+--------------------------------
+
+The code for generating email messages in Roundup has been refactored. A
+new module, ``roundup.mailer`` contains most of the nuts-n-bolts required
+to generate email messages from Roundup.
+
+In addition, the ``IssueClass`` methods ``nosymessage()`` and
+``send_message()`` have both been altered so that they don't require the
+message id parameter. This means that change notes with no associated
+change message may now be generated much more easily.
+
+The roundupdb nosymessage() method also accepts a ``bcc`` argument which
+specifies additional userids to send the message to that will not be
+included in the To: header of the message.
+
+
+Registration confirmation by email
+----------------------------------
+
+Users may now reply to their registration confirmation email, and the
+roundup mail gateway will complete their registration.
+
+
+``roundup-mailgw`` now supports IMAP
+------------------------------------
+
+To retrieve from an IMAP mailbox, use a *cron* entry similar to the
+POP one::
+
+  0,10,20,30,40,50 * * * * /usr/local/bin/roundup-mailgw /opt/roundup/trackers/support imap <imap_spec>
+
+where imap_spec is "``username:password at server``" that specifies the roundup
+submission user's IMAP account name, password and server. You may
+optionally include a mailbox to use other than the default ``INBOX`` with
+"``imap username:password at server mailbox``".
+
+If you have a secure (ie. HTTPS) IMAP server then you may use ``imaps``
+in place of ``imap`` in the command to use a secure connection.
+
+
+Database configuration
+======================
+
+Postgresql added as a backend option
+------------------------------------
+
+Trackers may now use the postgresql RDBMS as a database store.
+
+Postgresql is a good choice if you expect your tracker to grow very large,
+and are expecting many users.
+
+
+API change
+----------
+
+The Database.curuserid attribute was removed. Any code referencing this
+attribute should be replaced with a call to Database.getuid().
+
+
+New configuration options
+-------------------------
+
+- Added DEFAULT_TIMEZONE which allows the tracker to have a different
+  default to UTC when users don't specify their own preference.
+
+- Added EMAIL_CHARSET (in 0.6.6, but worth mentioning here) which hard-codes
+  the character set to be used when sending email from Roundup. This works
+  around some email clients' inability to cope well with UTF-8 (the
+  default).
+
+- ERROR_MESSAGES_TO and DISPATCHER_EMAIL as described above in `Dispatcher
+  configuration`_.
+
+
+Typed columns in RDBMS backends
+-------------------------------
+
+The SQLite, MySQL and Postgresql backends now create tables with
+appropriate column datatypes (not just varchar).
+
+Your database will be automatically migrated to use the new schemas, but
+it will take time. It's probably a good idea to make sure you do this as
+part of the upgrade when users are not expected to be using the system.
+
+
+Permission setup
+----------------
+
+0.7 automatically sets up the Edit and View Permissions for all classes,
+thus you don't need to do so. Feel free to remove the code::
+
+    # Add new Permissions for this schema
+    for cl in 'issue', 'file', 'msg', 'user', 'query', 'keyword':
+        db.security.addPermission(name="Edit", klass=cl,
+            description="User is allowed to edit "+cl)
+        db.security.addPermission(name="View", klass=cl,
+            description="User is allowed to access "+cl)
+
+from your ``dbinit.py``.
+
+
+New "actor" property
+--------------------
+
+Roundup's database has a new per-item property "actor" which reflects the
+user performing the last "actvitiy". See the classic template for ways to
+integrate this new property into your interface.
+
+The property will be automatically added to your existing database.
+
+
+New Reject exception for Auditors
+---------------------------------
+
+An auditor may raise this exception when the current create or set
+operation should be stopped.
+
+It is up to the specific interface invoking the create or set to
+handle this exception sanely. For example:
+
+- mailgw will trap and ignore Reject for file attachments and messages
+- cgi will trap and present the exception in a nice format
+
+
+New auditor fixes Outlook bug
+-----------------------------
+
+The new optional auditor ``detectors/emailauditor.py`` fires whenever a
+new file entity is created.
+
+If the file is of type message/rfc822, we tack on the extension .mht.
+
+The reason for this is that Microsoft Internet Explorer will not open
+things with a .eml attachment, as they deem it 'unsafe'. Worse yet,
+they'll just give you an incomprehensible error message. For more 
+information, please see: 
+
+http://support.microsoft.com/default.aspx?scid=kb;EN-US;825803
+
+Their suggested work around is (excerpt):
+
+ WORKAROUND
+
+ To work around this behavior, rename the .EML file that the URL
+ links to so that it has a .MHT file name extension, and then update
+ the URL to reflect the change to the file name. To do this:
+
+ 1. In Windows Explorer, locate and then select the .EML file that
+    the URL links.
+ 2. Right-click the .EML file, and then click Rename.
+ 3. Change the file name so that the .EML file uses a .MHT file name
+    extension, and then press ENTER.
+ 4. Updated the URL that links to the file to reflect the new file
+    name extension.
+
+
+New script for copying users
+----------------------------
+
+A new script, ``scripts/copy-user.py``, will copy users from one tracker
+to another.  Example usage::
+
+    copy-user.py /roundup/tracker1 /roundup/tracker2 `seq 3 10` 14 16
+
+which copies users 3, 4, 5, 6, 7, 8, 9, 10, 14 and 16.
+
+
+Other improvements
+------------------
+
+- All RDBMS backends now have indexes automatically created on critical
+  table columns.
+
+- Additionally, the RDBMS backends also implement their own session,
+  one-time-key and full-text indexing stores. These were previously external
+  dbm stores. This change allows control of locking the database to be
+  completely handed over to the RDBMS.
+
+- Date values capture fractions of seconds now. Note that the MySQL backend
+  is not capable of storing this precision though, so it will be lost for
+  users of that backend.
+
+- The roundup-admin "export" and "import" commands now handle the database
+  journals too. This means that exports from previous versions of Roundup
+  will not work under 0.7!
+
+
+.. _`customisation documentation`: customizing.html

Added: tracker/vendor/roundup/current/doc/whatsnew-0.8.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/doc/whatsnew-0.8.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,187 @@
+=========================
+What's New in Roundup 0.8
+=========================
+
+For those completely new to Roundup, you might want to look over the very
+terse features__ page.
+
+__ features.html
+
+.. contents::
+
+In Summary
+==========
+
+(this information copied directly from the ``CHANGES.txt`` file)
+
+XXX this section needs more detail
+
+- create a new RDBMS cursor after committing
+- roundup-admin reindex command may now work on single items or classes
+
+- roundup-server options -g and -u accept both ids and names (sf bug 983769)
+- roundup-server now has a configuration file (-C option)
+- roundup windows service may be installed with command line options
+  recognized by roundup-server (but not tracker specification arguments).
+  Use this to specify server configuration file for the service.
+
+- added option to turn off registration confirmation via email
+  ("instant_registration" in config) (sf rfe 922209)
+
+
+
+Performance improvements
+========================
+
+We don't try to import all backends in backends.__init__ unless we *want*
+to.
+
+Roundup may now use the Apache mod_python interface (see installation.txt)
+which is much faster than the standard cgi-bin and a little faster than
+roundup-server.
+
+There is now an experimental multi-thread server which should allow faster
+concurrent access.
+
+In the hyperdb, a few other speedups were implemented, such as:
+
+- record journaltag lookup ("fixes" sf bug 998140)
+- unless in debug mode, keep a single persistent connection through a
+  single web or mailgw request.
+- remove "manual" locking of sqlite database
+
+
+Logging of internal messages
+============================
+
+Roundup's previously ad-hoc logging of events has been cleaned up and is
+now configured in a single place in the tracker configuration file.
+
+The `customization documentation`_ has more details on how this is
+configured.
+
+roundup-mailgw now logs fatal exceptions rather than mailing them to admin.
+
+
+Security Changes
+================
+
+``security.addPermissionToRole()`` has been extended to allow skipping the
+separate getPermission call.
+
+
+Password Storage
+----------------
+
+Added MD5 scheme for password hiding. This extends the existing SHA and
+crypt methods and is useful if you have an existing MD5 password database.
+
+
+Permission Definitions
+----------------------
+
+Permissions may now be defined on a per-property basis, allowing access to
+only specific properties on items. 
+
+Permissions may also have code attached which is executed to check whether
+the Permission is valid for the current user and item.
+
+Permissions are now automatically checked when information is rendered
+through the web. This includes:
+
+1. View checks for properties when being rendered via the ``plain()`` or
+   similar methods. If the check fails, the text "[hidden]" will be
+   displayed.
+2. Edit checks for properties when the edit field is being rendered via
+   the ``field()`` or similar methods. If the check fails, the property
+   will be rendered via the ``plain()`` method (see point 1. for additional
+   checking performed)
+3. View checks are performed in index pages for each item being displayed
+   such that if the user does not have permission, the row is not rendered.
+
+
+Extending Roundup
+=================
+
+To write extension code for Roundup you place a file in the tracker home
+``extensions`` directory. See the `customisation documentation`_ for more
+information about how this is done.
+
+
+8-bit character set support in Web interface
+============================================
+
+This is used to override the UTF-8 default. It may be overridden in both
+forms and a browser cookie.
+
+- In forms, use the ``@charset`` variable.
+- To use the cookie override, have the ``roundup_charset`` cookie set.
+
+In both cases, the value is a valid charset name (eg. ``utf-8`` or
+``kio8-r``).
+
+Inside Roundup, all strings are stored and processed in utf-8.
+Unfortunately, some older browsers do not work properly with
+utf-8-encoded pages (e.g. Netscape Navigator 4 displays wrong
+characters in form fields).  This version allows to change
+the character set for http transfers.  To do so, you may add
+the following code to your ``page.html`` template::
+
+ <tal:block define="uri string:${request/base}${request/env/PATH_INFO}">
+  <a tal:attributes="href python:request.indexargs_url(uri,
+   {'@charset':'utf-8'})">utf-8</a>
+  <a tal:attributes="href python:request.indexargs_url(uri,
+   {'@charset':'koi8-r'})">koi8-r</a>
+ </tal:block>
+
+(substitute ``koi8-r`` with the appropriate charset for your language).
+Charset preference is kept in the browser cookie ``roundup_charset``.
+
+``meta http-equiv`` lines added to the tracker templates in version 0.6.0
+should be changed to include actual character set name::
+
+ <meta http-equiv="Content-Type"
+  tal:attributes="content string:text/html;; charset=${request/client/charset}"
+ />
+
+Actual charset is also sent in the http header.
+
+
+Web Interface Miscellanea
+=========================
+
+The web interface has seen some changes:
+
+Editing
+
+Templating
+  We implement __nonzero__ for HTMLProperty - properties may now be used in
+  boolean conditions (eg ``tal:condition="issue/nosy"`` will be false if
+  the nosy list is empty).
+
+  We added a default argument to the DateHTMLProperty.field method, and an
+  optional Interval (string or object) to the DateHTMLProperty.now
+
+  We've added a multiple selection Link/Multilink search field macro to the
+  default classic page.html template.
+
+  We relaxed hyperlinking in web interface (accept "issue123" or "Issue 123")
+
+  The listing popup may be used in query forms.
+
+Standard templates
+  We hide "(list)" popup links when issue is only viewable
+
+  The issue search page now has fields to allow no sorting / grouping of
+  the results.
+
+  The default page.html template now has a search box in the top right
+  corner which performs a full-text search of issues. The "show issue"
+  quick jump form in the sidebar has had its font size reduced to use less
+  space.
+
+Web server
+  The builtin web server may now perform HTTP Basic Authentication by
+  itself.
+
+.. _`customization documentation`: customizing.html

Added: tracker/vendor/roundup/current/frontends/README.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/frontends/README.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,8 @@
+This directory contains alternate front-ends for Roundup.
+
+Zope - ZRoundup
+---------------
+
+This installs as a regular Zope product. See Roundup's doc/installation.txt
+for more info.
+

Added: tracker/vendor/roundup/current/frontends/ZRoundup/.cvsignore
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/frontends/ZRoundup/.cvsignore	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,2 @@
+*.pyc
+*.pyo

Added: tracker/vendor/roundup/current/frontends/ZRoundup/ZRoundup.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/frontends/ZRoundup/ZRoundup.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,218 @@
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+# 
+# $Id: ZRoundup.py,v 1.22 2006/01/25 03:43:04 richard Exp $
+#
+''' ZRoundup module - exposes the roundup web interface to Zope
+
+This frontend works by providing a thin layer that sits between Zope and the
+regular CGI interface of roundup, providing the web frontend with the minimum
+of effort.
+
+This means that the regular CGI interface does all authentication quite
+independently of Zope. The roundup code is kept in memory though, and it
+runs in the same server as all your other Zope stuff, so it does have _some_
+advantages over regular CGI :)
+'''
+
+import urlparse
+
+from Globals import InitializeClass, HTMLFile
+from OFS.SimpleItem import Item
+from OFS.PropertyManager import PropertyManager
+from Acquisition import Explicit, Implicit
+from Persistence import Persistent
+from AccessControl import ClassSecurityInfo
+from AccessControl import ModuleSecurityInfo
+modulesecurity = ModuleSecurityInfo()
+
+import roundup.instance
+from roundup.cgi import client
+
+modulesecurity.declareProtected('View management screens',
+    'manage_addZRoundupForm')
+manage_addZRoundupForm = HTMLFile('dtml/manage_addZRoundupForm', globals())
+
+modulesecurity.declareProtected('Add Z Roundups', 'manage_addZRoundup')
+def manage_addZRoundup(self, id, instance_home, REQUEST):
+    """Add a ZRoundup product """
+    # validate the instance_home
+    roundup.instance.open(instance_home)
+    self._setObject(id, ZRoundup(id, instance_home))
+    return self.manage_main(self, REQUEST)
+
+class RequestWrapper:
+    '''Make the Zope RESPONSE look like a BaseHTTPServer
+    '''
+    def __init__(self, RESPONSE):
+        self.RESPONSE = RESPONSE
+        self.wfile = self.RESPONSE
+    def send_response(self, status):
+        self.RESPONSE.setStatus(status)
+    def send_header(self, header, value):
+        self.RESPONSE.addHeader(header, value)
+    def end_headers(self):
+        # not needed - the RESPONSE object handles this internally on write()
+        pass
+
+class FormItem:
+    '''Make a Zope form item look like a cgi.py one
+    '''
+    def __init__(self, value):
+        self.value = value
+        if hasattr(self.value, 'filename'):
+            self.filename = self.value.filename
+            self.value = self.value.read()
+
+class FormWrapper:
+    '''Make a Zope form dict look like a cgi.py one
+    '''
+    def __init__(self, form):
+        self.__form = form
+    def __getitem__(self, item):
+        entry = self.__form[item]
+        if isinstance(entry, type([])):
+            entry = map(FormItem, entry)
+        else:
+            entry = FormItem(entry)
+        return entry
+    def getvalue(self, key, default=None):
+        if self.__form.has_key(key):
+            return self.__form[key]
+        else:
+            return default
+    def has_key(self, item):
+        return self.__form.has_key(item)
+    def keys(self):
+        return self.__form.keys()
+
+    def __repr__(self):
+        return '<ZRoundup.FormWrapper %r>'%self.__form
+
+class ZRoundup(Item, PropertyManager, Implicit, Persistent):
+    '''An instance of this class provides an interface between Zope and
+       roundup for one roundup instance
+    '''
+    meta_type =  'Z Roundup'
+    security = ClassSecurityInfo()
+
+    def __init__(self, id, instance_home):
+        self.id = id
+        self.instance_home = instance_home
+
+    # define the properties that define this object
+    _properties = (
+        {'id':'id', 'type': 'string', 'mode': 'w'},
+        {'id':'instance_home', 'type': 'string', 'mode': 'w'},
+    )
+    property_extensible_schema__ = 0
+
+    # define the tabs for the management interface
+    manage_options= PropertyManager.manage_options + (
+        {'label': 'View', 'action':'index_html'},
+    ) + Item.manage_options
+
+    icon = "misc_/ZRoundup/icon"
+
+    security.declarePrivate('roundup_opendb')
+    def roundup_opendb(self):
+        '''Open the roundup instance database for a transaction.
+        '''
+        tracker = roundup.instance.open(self.instance_home)
+        request = RequestWrapper(self.REQUEST['RESPONSE'])
+        env = self.REQUEST.environ
+
+        # figure out the path components to set
+        url = urlparse.urlparse( self.absolute_url() )
+        path = url[2]
+        path_components = path.split( '/' )
+
+        # special case when roundup is '/' in this virtual host,
+        if path == "/" :
+            env['SCRIPT_NAME'] = "/"
+            env['TRACKER_NAME'] = ''
+        else :
+            # all but the last element is the path
+            env['SCRIPT_NAME'] = '/'.join( path_components[:-1] )
+            # the last element is the name
+            env['TRACKER_NAME'] = path_components[-1]
+
+        form = FormWrapper(self.REQUEST.form)
+        if hasattr(tracker, 'Client'):
+            return tracker.Client(tracker, request, env, form)
+        return client.Client(tracker, request, env, form)
+
+    security.declareProtected('View', 'index_html')
+    def index_html(self):
+        '''Alias index_html to roundup's index
+        '''
+        # Redirect misdirected requests -- bugs 558867 , 565992
+        # PATH_INFO, as defined by the CGI spec, has the *real* request path
+        orig_path = self.REQUEST.environ['PATH_INFO']
+        if orig_path[-1] != '/' : 
+            url = urlparse.urlparse( self.absolute_url() )
+            url = list( url ) # make mutable
+            url[2] = url[2]+'/' # patch
+            url = urlparse.urlunparse( url ) # reassemble
+            RESPONSE = self.REQUEST.RESPONSE
+            RESPONSE.setStatus( "MovedPermanently" ) # 301
+            RESPONSE.setHeader( "Location" , url )
+            return RESPONSE
+
+        client = self.roundup_opendb()
+        # fake the path that roundup should use
+        client.split_path = ['index']
+        return client.main()
+
+    def __getitem__(self, item):
+        '''All other URL accesses are passed throuh to roundup
+        '''
+        return PathElement(self, item).__of__(self)
+
+class PathElement(Item, Implicit):
+    def __init__(self, zr, path):
+        self.zr = zr
+        self.path = path
+
+    def __getitem__(self, item):
+        ''' Get a subitem.
+        '''
+        return PathElement(self.zr, self.path + '/' + item).__of__(self)
+
+    def index_html(self, REQUEST=None):
+        ''' Actually call through to roundup to handle the request.
+        '''
+        try:
+            client = self.zr.roundup_opendb()
+            # fake the path that roundup should use
+            client.path = self.path
+            # and call roundup to do something 
+            client.main()
+            return ''
+        except client.NotFound:
+            raise 'NotFound', REQUEST.URL
+            pass
+        except:
+            import traceback
+            traceback.print_exc()
+            # all other exceptions in roundup are valid
+            raise
+
+InitializeClass(ZRoundup)
+modulesecurity.apply(globals())
+
+
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/frontends/ZRoundup/__init__.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/frontends/ZRoundup/__init__.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,56 @@
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+# 
+# $Id: __init__.py,v 1.4 2002/10/10 03:47:27 richard Exp $
+#
+__version__='1.0'
+
+import os
+# figure where ZRoundup is installed
+here = None
+if os.environ.has_key('INSTANCE_HOME'):
+    here = os.environ['INSTANCE_HOME']
+    path = os.path.join(here, 'Products', 'ZRoundup')
+    if not os.path.exists(path):
+        path = os.path.join(here, 'lib', 'python', 'Products', 'ZRoundup')
+        if not os.path.exists(path):
+            here = None
+if here is None:
+    from __main__ import here
+    path = os.path.join(here, 'Products', 'ZRoundup')
+    if not os.path.exists(path):
+        path = os.path.join(here, 'lib', 'python', 'Products', 'ZRoundup')
+        if not os.path.exists(path):
+            raise ValueError, "Can't determine where ZRoundup is installed"
+
+# product initialisation
+import ZRoundup
+def initialize(context):
+    context.registerClass(
+        ZRoundup, meta_type = 'Z Roundup',
+        constructors = (
+            ZRoundup.manage_addZRoundupForm, ZRoundup.manage_addZRoundup
+        )
+    )
+
+# set up the icon
+from ImageFile import ImageFile
+misc_ = {
+    'icon': ImageFile('icons/tick_symbol.gif', path), 
+}
+
+
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/frontends/ZRoundup/dtml/manage_addZRoundupForm.dtml
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/frontends/ZRoundup/dtml/manage_addZRoundupForm.dtml	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,5 @@
+<form action="manage_addZRoundup">
+ID: <input type="text" name="id"><br>
+Instance Home: <input type="text" name="instance_home"><br>
+<input type="submit">
+</form>

Added: tracker/vendor/roundup/current/frontends/ZRoundup/icons/tick_symbol.gif
==============================================================================
Binary file. No diff available.

Added: tracker/vendor/roundup/current/frontends/ZRoundup/refresh.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/frontends/ZRoundup/refresh.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,2 @@
+The existence of this file enables the Zope product refresh option.
+Read the Zope documentation for more info about product refresh.

Added: tracker/vendor/roundup/current/locale/.cvsignore
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/locale/.cvsignore	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,5 @@
+*.mo
+*.bak
+*.swp
+*.tmp
+*.poedit
\ No newline at end of file

Added: tracker/vendor/roundup/current/locale/GNUmakefile
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/locale/GNUmakefile	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,56 @@
+# Extract translatable strings from Roundup sources,
+# update and compile all existing translations
+#
+# $Id: GNUmakefile,v 1.10 2006/03/04 09:04:29 a1s Exp $
+
+# tool locations
+XPOT ?= xpot
+MSGFMT ?= msgfmt
+MSGMERGE ?= msgmerge
+XGETTEXT ?= xgettext
+PYTHON ?= python
+
+TEMPLATE=roundup.pot
+
+PACKAGES=$(shell find ../roundup -name '*.py'|sed -e 's,/[^/]*$$,,'|sort|uniq)
+SOURCES=$(PACKAGES:=/*.py)
+PO_FILES=$(wildcard *.po)
+MO_FILES=$(PO_FILES:.po=.mo)
+RUN_PYTHON=PYTHONPATH=../build/lib $(PYTHON) -O
+
+all: dist
+
+help:
+	@echo "$(MAKE)           - build MO files.  Run this before sdist"
+	@echo "$(MAKE) template  - update message template from sources"
+	@echo "$(MAKE) locale.po - update message file from template"
+	@echo "$(MAKE) locale.mo - compile individual message file"
+	@echo "$(MAKE) help      - this text"\
+
+# This will rebuild all MO files without updating their corresponding PO
+# files first.  Run before creating Roundup distribution (hence the name).
+# PO files should be updated by their translators only, automatic update
+# adds unwanted fuzzy labels.
+dist:
+	for file in $(PO_FILES); do \
+	  ${MSGFMT} -o `basename $$file .po`.mo $$file; \
+	done
+
+template:
+	${XPOT} -n -o $(TEMPLATE) $(SOURCES)
+	${RUN_PYTHON} ../roundup/cgi/TAL/talgettext.py -u $(TEMPLATE) \
+	  ../templates/classic/html/*.html ../templates/minimal/html/*.html
+	${XGETTEXT} -j -w 80 -F \
+	  --msgid-bugs-address=roundup-devel at lists.sourceforge.net \
+	  --copyright-holder="See Roundup README.txt" \
+	  -o $(TEMPLATE) $(SOURCES)
+
+# helps to check template file before check in
+diff:
+	cvs diff roundup.pot|grep -v '^[-+]#'|vim -Rv -
+
+%.po: $(TEMPLATE)
+	${MSGMERGE} -U --suffix=.bak $@ $<
+
+%.mo: %.po
+	${MSGFMT} --statistics -o $@ $<

Added: tracker/vendor/roundup/current/locale/de.po
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/locale/de.po	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,2989 @@
+# German message file for Roundup Issue Tracker
+# Stefan Niederhauser <stefan.niederhauser at unibas.ch>, 2004.
+#
+# $Id: de.po,v 1.3 2005/01/13 23:08:12 richard Exp $
+#
+# roundup.pot revision 1.8
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Roundup 0.7.0\n"
+"Report-Msgid-Bugs-To: roundup-devel at lists.sourceforge.net\n"
+"POT-Creation-Date: 2004-12-08 10:25+0200\n"
+"PO-Revision-Date: 2004-07-05 15:00+0100\n"
+"Last-Translator: Stefan Niederhauser <stefan.niederhauser at unibas.ch>\n"
+"Language-Team: German Translators <roundup-devel at lists.sourceforge.net>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=ISO-8859-1\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n != 1;\n"
+
+# ../roundup/admin.py:83 :949 :998 :1020
+#: ../roundup/admin.py:84 ../roundup/admin.py:954 ../roundup/admin.py:1003
+#: ../roundup/admin.py:1025
+#, python-format
+msgid "no such class \"%(classname)s\""
+msgstr "Die Klasse \"%(classname)s\" existiert nicht"
+
+# ../roundup/admin.py:93 :97
+#: ../roundup/admin.py:94 ../roundup/admin.py:98
+#, python-format
+msgid "argument \"%(arg)s\" not propname=value"
+msgstr ""
+"Der Parameter \"%(arg)s\" entspricht nicht dem Format Eigenschaft=Wert"
+
+#: ../roundup/admin.py:111
+#, python-format
+msgid ""
+"Problem: %(message)s\n"
+"\n"
+msgstr ""
+"Problem: %(message)s\n"
+"\n"
+
+#: ../roundup/admin.py:112
+#, python-format
+msgid ""
+"%(message)sUsage: roundup-admin [options] [<command> <arguments>]\n"
+"\n"
+"Options:\n"
+" -i instance home  -- specify the issue tracker \"home directory\" to "
+"administer\n"
+" -u                -- the user[:password] to use for commands\n"
+" -d                -- print full designators not just class id numbers\n"
+" -c                -- when outputting lists of data, comma-separate them.\n"
+"                      Same as '-S \",\"'.\n"
+" -S <string>       -- when outputting lists of data, string-separate them\n"
+" -s                -- when outputting lists of data, space-separate them.\n"
+"                      Same as '-S \" \"'.\n"
+"\n"
+" Only one of -s, -c or -S can be specified.\n"
+"\n"
+"Help:\n"
+" roundup-admin -h\n"
+" roundup-admin help                       -- this help\n"
+" roundup-admin help <command>             -- command-specific help\n"
+" roundup-admin help all                   -- all available help\n"
+msgstr ""
+"%(message)sVerwendung: roundup-admin [Optionen] [<Befehl> <Parameter>]\n"
+"\n"
+"Optionen:\n"
+" -i <Instanzverzeichnis> -- Tracker-Instanz zur Administration auswählen\n"
+" -u                -- Benutzer[:Password] für das Ausführen von Befehlen\n"
+" -d                -- Lange Bezeichner anzeigen statt Klassen-Ids\n"
+" -c                -- Komma-getrennte Listenausgabe (CSV).\n"
+"                      Analog zu '-S \",\"'.\n"
+" -S <Zeichenkette> -- Trennzeichen bei der Listenausgabe.\n"
+" -s                -- Leerschlag als Trennzeichen verwenden.\n"
+"                      Analog zu '-S \" \"'.\n"
+"\n"
+" Nur eine der Optionen -s, -c or -S kann gewählt werden.\n"
+"\n"
+"Hilfe:\n"
+" roundup-admin -h\n"
+" roundup-admin help                       -- diese Kurzhilfe anzeigen\n"
+" roundup-admin help <Befehl>              -- Hilfe zu einem Befehl anzeigen\n"
+" roundup-admin help all                   -- Sämtliche Hilfe anzeigen\n"
+
+#: ../roundup/admin.py:137
+msgid "Commands:"
+msgstr "Befehle:"
+
+#: ../roundup/admin.py:144
+msgid ""
+"Commands may be abbreviated as long as the abbreviation\n"
+"matches only one command, e.g. l == li == lis == list."
+msgstr ""
+"Befehle können abgekürzt werden, solange sie eindeutig bleiben, \n"
+"z.B. l == li == lis == list."
+
+#: ../roundup/admin.py:174
+msgid ""
+"\n"
+"All commands (except help) require a tracker specifier. This is just\n"
+"the path to the roundup tracker you're working with. A roundup tracker\n"
+"is where roundup keeps the database and configuration file that defines\n"
+"an issue tracker. It may be thought of as the issue tracker's \"home\n"
+"directory\". It may be specified in the environment variable TRACKER_HOME\n"
+"or on the command line as \"-i tracker\".\n"
+"\n"
+"A designator is a classname and a nodeid concatenated, eg. bug1, "
+"user10, ...\n"
+"\n"
+"Property values are represented as strings in command arguments and in the\n"
+"printed results:\n"
+" . Strings are, well, strings.\n"
+" . Date values are printed in the full date format in the local time zone,\n"
+"   and accepted in the full format or any of the partial formats explained\n"
+"   below.\n"
+" . Link values are printed as node designators. When given as an argument,\n"
+"   node designators and key strings are both accepted.\n"
+" . Multilink values are printed as lists of node designators joined\n"
+"   by commas.  When given as an argument, node designators and key\n"
+"   strings are both accepted; an empty string, a single node, or a list\n"
+"   of nodes joined by commas is accepted.\n"
+"\n"
+"When property values must contain spaces, just surround the value with\n"
+"quotes, either ' or \". A single space may also be backslash-quoted. If a\n"
+"value must contain a quote character, it must be backslash-quoted or inside\n"
+"quotes. Examples:\n"
+"           hello world      (2 tokens: hello, world)\n"
+"           \"hello world\"    (1 token: hello world)\n"
+"           \"Roch'e\" Compaan (2 tokens: Roch'e Compaan)\n"
+"           Roch\\'e Compaan  (2 tokens: Roch'e Compaan)\n"
+"           address=\"1 2 3\"  (1 token: address=1 2 3)\n"
+"           \\\\               (1 token: \\)\n"
+"           \\n\\r\\t           (1 token: a newline, carriage-return and "
+"tab)\n"
+"\n"
+"When multiple nodes are specified to the roundup get or roundup set\n"
+"commands, the specified properties are retrieved or set on all the listed\n"
+"nodes.\n"
+"\n"
+"When multiple results are returned by the roundup get or roundup find\n"
+"commands, they are printed one per line (default) or joined by commas (with\n"
+"the -c) option.\n"
+"\n"
+"Where the command changes data, a login name/password is required. The\n"
+"login may be specified as either \"name\" or \"name:password\".\n"
+" . ROUNDUP_LOGIN environment variable\n"
+" . the -u command-line option\n"
+"If either the name or password is not supplied, they are obtained from the\n"
+"command-line.\n"
+"\n"
+"Date format examples:\n"
+"  \"2000-04-17.03:45\" means <Date 2000-04-17.08:45:00>\n"
+"  \"2000-04-17\" means <Date 2000-04-17.00:00:00>\n"
+"  \"01-25\" means <Date yyyy-01-25.00:00:00>\n"
+"  \"08-13.22:13\" means <Date yyyy-08-14.03:13:00>\n"
+"  \"11-07.09:32:43\" means <Date yyyy-11-07.14:32:43>\n"
+"  \"14:25\" means <Date yyyy-mm-dd.19:25:00>\n"
+"  \"8:47:11\" means <Date yyyy-mm-dd.13:47:11>\n"
+"  \".\" means \"right now\"\n"
+"\n"
+"Command help:\n"
+msgstr ""
+"\n"
+"Sie müssen für sämtliche Befehle - ausser für die Hilfe - das Verzeichnis\n"
+"einer Tracker-Instanz angeben. Dort wird die Konfiguration gespeichert und\n"
+" - je nach Datenbank - auch die Daten. Das Tracker-Verzeichnis kann über\n"
+"die Umgebungsvariable TRACKER_HOME oder die Option \"-i Verzeichnis\"\n"
+"angegeben werden.\n"
+"\n"
+"Ein Bezeichner besteht aus einem Klassennamen und einer ID, zum Beispiel\n"
+"\"issue12\"\n"
+"\n"
+"Eigenschaften werden als Zeichenketten übergeben und angezeigt.\n"
+" . Eine Zeichenkette (\"String\") wird direkt ausgegeben.\n"
+" . Datumswerte werden als vollständiges Datum in der lokalen Zeitzone\n"
+"   ausgegeben und können im vollständigen Format oder in einem Teilformat\n"
+"   eingeben werden (siehe unten).\n"
+" . Links zu anderen Einträgen werden mit dem Bezeichner dargestellt.\n"
+"   Bei der Eingabe wird entweder der Bezeichner, oder nur der Schlüssel\n"
+"   angegeben.\n"
+" . Bei Mehrfach-Links werden die verlinkten Bezeichner mit Komma getrennt\n"
+"   ausgegeben. Bei der Eingabe können Bezeichner oder Schlüssel\n"
+"   mit Kommas getrennt eingegeben werden.\n"
+"\n"
+"Falls Eigenschaften Leerschläge enthalten, müssen die Werte in\n"
+"\"Anführungszeichen\" eingeschlossen werden. Leerschläge können auch mit\n"
+"einem \\Backslash geschützt werden. Ebenso müssen Anführungszeichen im Wert\n"
+"mit einem Backslash versehen werden, einfache ' wie doppelte \".\n"
+"Beispiele:\n"
+"           Hallo Welt          (2 Werte: Hallo, Welt)\n"
+"           \"Hallo Welt\"      (1 Wert: Hallo Welt)\n"
+"           \"Sophie's\" Welt   (2 Werte: Sophie's, Welt)\n"
+"           Adresse=\"1 2 3\"   (1 Wert: Address=1 2 3)\n"
+"           \\\\                (1 Wert: \\)\n"
+"           \\n\\r\\t           (1 Wert: Zeilenumbruch + CR + Tab)\n"
+"\n"
+"Wenn bei einer Abfrage oder einer Änderung mehrere Einträge angegeben\n"
+"werden, so werden die gewünschten Eigenschaften aller Einträge angezeigt\n"
+"order geändert.\n"
+"\n"
+"Wenn ein Befehl \"get\" oder \"find\" mehrere Einträge zurückgibt, so \n"
+"werden diese Zeile für Zeile, oder (mit der -c Option) kommagetrennet\n"
+"ausgegeben.\n"
+"\n"
+"Bei Änderungen wird ein Benutzername und ein Passwort benötigt.\n"
+"Diese Angaben können in der Umgebungsvariable ROUNDUP_LOGIN oder mit der\n"
+"Option -u gemacht werden, entweder als \"Benutzername\" oder als\n"
+"\"benutzername:passwort\".\n"
+"\n"
+"Beispiele für Datumsformate:\n"
+"  \"2000-04-17.03:45\" ergibt <Date 2000-04-17.08:45:00>\n"
+"  \"2000-04-17\" ergibt <Date 2000-04-17.00:00:00>\n"
+"  \"01-25\" ergibt <Date yyyy-01-25.00:00:00>\n"
+"  \"08-13.22:13\" ergibt <Date yyyy-08-14.03:13:00>\n"
+"  \"11-07.09:32:43\" ergibt <Date yyyy-11-07.14:32:43>\n"
+"  \"14:25\" ergibt <Date yyyy-mm-dd.19:25:00>\n"
+"  \"8:47:11\" ergibt <Date yyyy-mm-dd.13:47:11>\n"
+"  \".\" ergibt \"jetzt\"\n"
+"\n"
+"Befehlshilfe:\n"
+
+#: ../roundup/admin.py:237
+#, python-format
+msgid "%s:"
+msgstr "%s:"
+
+#: ../roundup/admin.py:242
+msgid ""
+"Usage: help topic\n"
+"        Give help about topic.\n"
+"\n"
+"        commands  -- list commands\n"
+"        <command> -- help specific to a command\n"
+"        initopts  -- init command options\n"
+"        all       -- all available help\n"
+"        "
+msgstr ""
+"Verwendung: help Thema\n"
+"        Zeigt die Hilfe für ein Thema ein.\n"
+"\n"
+"        commands  -- Befehle auflisten\n"
+"        <command> -- Hilfe zu einem bestimmten Befehl\n"
+"        initopts  -- Optionen zur Initialisierung\n"
+"        all       -- Sämtlichen Hilfetext anzeigen\n"
+"        "
+
+#: ../roundup/admin.py:265
+#, python-format
+msgid "Sorry, no help for \"%(topic)s\""
+msgstr "Zum Thema \"%(topic)s\" existiert leider kein Hilfetext"
+
+# ../roundup/admin.py:336 :382
+#: ../roundup/admin.py:337 ../roundup/admin.py:386
+msgid "Templates:"
+msgstr "Vorlagen:"
+
+# ../roundup/admin.py:339 :393
+#: ../roundup/admin.py:340 ../roundup/admin.py:397
+msgid "Back ends:"
+msgstr "Datenbanken:"
+
+#: ../roundup/admin.py:343
+msgid ""
+"Usage: install [template [backend [admin password]]]\n"
+"        Install a new Roundup tracker.\n"
+"\n"
+"        The command will prompt for the tracker home directory\n"
+"        (if not supplied through TRACKER_HOME or the -i option).\n"
+"        The template, backend and admin password may be specified\n"
+"        on the command-line as arguments, in that order.\n"
+"\n"
+"        The initialise command must be called after this command in order\n"
+"        to initialise the tracker's database. You may edit the tracker's\n"
+"        initial database contents before running that command by editing\n"
+"        the tracker's dbinit.py module init() function.\n"
+"\n"
+"        See also initopts help.\n"
+"        "
+msgstr ""
+"Verwendung: install [Vorlage [Datenbanktyp [Administratorpasswort]]]\n"
+"        Installiert einen neuen Roundup-Tracker.\n"
+"\n"
+"        Sie werden aufgefordert, ein Tracker-Verzeichnis zu wählen\n"
+"        (falls Sie keines mit TRACKER_HOME oder -i angegeben haben),\n"
+"        sowie eine Vorlage, den Datenbanktyp und das Administrations-\n"
+"        passwort anzugeben.\n"
+"        Sie können die Vorlage, den Datenbanktyp und das Passwort\n"
+"        auch in dieser Reihenfolge auf der Kommandozeile angegen.\n"
+"\n"
+"        Nach der Installation müssen Sie die Datenbank mit dem Befehl \n"
+"        \"initialise\" einrichten. Zuvor können Sie in der Datei\n"
+"        \"dbinit.py\" die Funktion \"init()\" einen Anfangsbestand an\n"
+"        Daten programmieren.\n"
+"\n"
+"        Siehe auch unter dem Hilfethema \"initopts\".\n"
+"        "
+
+# ../roundup/admin.py:358 :483 :562 :612 :682 :703 :731 :802 :869 :940 :988
+# :1010 :1037 :1098 :1156
+#: ../roundup/admin.py:359 ../roundup/admin.py:441 ../roundup/admin.py:502
+#: ../roundup/admin.py:581 ../roundup/admin.py:631 ../roundup/admin.py:687
+#: ../roundup/admin.py:708 ../roundup/admin.py:736 ../roundup/admin.py:807
+#: ../roundup/admin.py:874 ../roundup/admin.py:945 ../roundup/admin.py:993
+#: ../roundup/admin.py:1015 ../roundup/admin.py:1042 ../roundup/admin.py:1104
+#: ../roundup/admin.py:1170
+msgid "Not enough arguments supplied"
+msgstr "Zu wenig Parameter übergeben"
+
+#: ../roundup/admin.py:365
+#, python-format
+msgid "Instance home parent directory \"%(parent)s\" does not exist"
+msgstr "Das angegebene Tracker-Verzeichnis \"%(parent)s\" existiert nicht"
+
+#: ../roundup/admin.py:373
+#, python-format
+msgid ""
+"WARNING: There appears to be a tracker in \"%(tracker_home)s\"!\n"
+"If you re-install it, you will lose all the data!\n"
+"Erase it? Y/N: "
+msgstr ""
+"WARNUNG: Im Verzeichnis \"%(tracker_home)s\" scheint bereits ein Tracker\n"
+"installiert zu sein! Eine erneute Installation löscht sämtliche Daten!\n"
+"Wirklich löschen? Y/N: "
+
+#: ../roundup/admin.py:388
+msgid "Select template [classic]: "
+msgstr "Template auswählen [classic]:"
+
+#: ../roundup/admin.py:399
+msgid "Select backend [anydbm]: "
+msgstr "Datenbank auswählen [anydbm]"
+
+#: ../roundup/admin.py:408
+#, python-format
+msgid ""
+"\n"
+" You should now edit the tracker configuration file:\n"
+"   %(config_file)s"
+msgstr ""
+"\n"
+" Sie sollten nun die Konfigurationsdatei des Trackers bearbeiten:\n"
+"   %(config_file)s"
+
+#: ../roundup/admin.py:417
+msgid " ... at a minimum, you must set following options:"
+msgstr " ... passen sie zumindest folgende Optionen an:"
+
+#: ../roundup/admin.py:422
+#, python-format
+msgid ""
+"\n"
+" If you wish to modify the database schema,\n"
+" you should also edit the schema file:\n"
+"   %(database_config_file)s\n"
+" You may also change the database initialisation file:\n"
+"   %(database_init_file)s\n"
+" ... see the documentation on customizing for more information.\n"
+msgstr ""
+"\n"
+" Um das Datenbank-Schema anzupassen, bearbeiten Sie die Datei:\n"
+"   %(database_config_file)s\n"
+" Sie können zudem auch den anfänglichen Datenbestand ändern:\n"
+"   %(database_init_file)s\n"
+" ... die Online-Dokumentation erhält ein eigenes Kapitel über Anpassungen.\n"
+
+#: ../roundup/admin.py:436
+msgid ""
+"Usage: genconfig <filename>\n"
+"        Generate a new tracker config file (ini style) with default values\n"
+"        in <filename>.\n"
+"        "
+msgstr ""
+"Verwendung: genconfig <filename>\n"
+"        Schreibt eine neue Tracker-Konfiguration (im \".ini\"-Format) mit \n"
+"        Vorgabe-Werten in die Datei <filename>.\n"
+"        "
+
+#. password
+#: ../roundup/admin.py:446
+msgid ""
+"Usage: initialise [adminpw]\n"
+"        Initialise a new Roundup tracker.\n"
+"\n"
+"        The administrator details will be set at this step.\n"
+"\n"
+"        Execute the tracker's initialisation function dbinit.init()\n"
+"        "
+msgstr ""
+"Verwendung: initialise [Administrationspasswort]\n"
+"        Initialisieren eines neuen Roundup-Trackers.\n"
+"\n"
+"        Der Administrator-Benutzer wird eingerichtet.\n"
+"\n"
+"        Die Funktion dbinit.init() wirf aufgerufen\n"
+"        "
+
+#: ../roundup/admin.py:460
+msgid "Admin Password: "
+msgstr "Admin Passwort: "
+
+#: ../roundup/admin.py:461
+msgid "       Confirm: "
+msgstr "    Bestätigen: "
+
+#: ../roundup/admin.py:465
+msgid "Instance home does not exist"
+msgstr "Tracker-Verzeichnis existiert nicht"
+
+#: ../roundup/admin.py:469
+msgid "Instance has not been installed"
+msgstr "Tracker-Instanz wurde nicht installiert"
+
+#: ../roundup/admin.py:474
+msgid ""
+"WARNING: The database is already initialised!\n"
+"If you re-initialise it, you will lose all the data!\n"
+"Erase it? Y/N: "
+msgstr ""
+"WARNUNG: Die Datenbank ist schon initialisiert!\n"
+"Eine erneute Initialisierung löscht sämtliche Daten!\n"
+"Wirklich löschen? Y/N: "
+
+#: ../roundup/admin.py:495
+msgid ""
+"Usage: get property designator[,designator]*\n"
+"        Get the given property of one or more designator(s).\n"
+"\n"
+"        Retrieves the property value of the nodes specified\n"
+"        by the designators.\n"
+"        "
+msgstr ""
+"Verwendung: get Eigenschaft Bezeichner[,Bezeichner]*\n"
+"        Gibt die Eigenschaft eines oder mehrerer Einträge zurück.\n"
+"\n"
+"        Diese Funktion zeigt Ihnen die Werte einer bestimmten\n"
+"        Eigenschaft der gewünschten Einträge an.\n"
+"        "
+
+# ../roundup/admin.py:516 :531
+#: ../roundup/admin.py:535 ../roundup/admin.py:550
+#, python-format
+msgid "property %s is not of type Multilink or Link so -d flag does not apply."
+msgstr ""
+"Die Eigenschaft %s ist kein Multilink oder Link. Das Option -d trifft "
+"deshalb hier nicht zu."
+
+# ../roundup/admin.py:539 :951 :1000 :1022
+#: ../roundup/admin.py:558 ../roundup/admin.py:956 ../roundup/admin.py:1005
+#: ../roundup/admin.py:1027
+#, python-format
+msgid "no such %(classname)s node \"%(nodeid)s\""
+msgstr ""
+"Es existiert kein Eintrag der Klasse %(classname)s mit der ID \"%(nodeid)s\""
+
+#: ../roundup/admin.py:560
+#, python-format
+msgid "no such %(classname)s property \"%(propname)s\""
+msgstr ""
+"Die Eigenschaft \"%(propname)s\" ist nicht definiert für die Klasse \"%"
+"(classname)s\""
+
+#: ../roundup/admin.py:569
+msgid ""
+"Usage: set items property=value property=value ...\n"
+"        Set the given properties of one or more items(s).\n"
+"\n"
+"        The items are specified as a class or as a comma-separated\n"
+"        list of item designators (ie \"designator[,designator,...]\").\n"
+"\n"
+"        This command sets the properties to the values for all designators\n"
+"        given. If the value is missing (ie. \"property=\") then the "
+"property\n"
+"        is un-set. If the property is a multilink, you specify the linked\n"
+"        ids for the multilink as comma-separated numbers (ie \"1,2,3\").\n"
+"        "
+msgstr ""
+"Verwendung: set Einträge Eigenschaft=Wert Eigenschaft=Wert ...\n"
+"        Bearbeitet den Eigenschaftswert eines oder mehrerer Einträge.\n"
+"\n"
+"        Für \"Einträge\" können Sie eine Klasse angeben, oder eine Liste\n"
+"        von einem oder mehreren kommagetrennten Bezeichnern aufgeführen\n"
+"        (\"Bezeichner[,Bezeichner]*\").\n"
+"\n"
+"        Der Wert der Eigenschaft wird für alle angegebenen Eintrge gesetzt.\n"
+"        Wenn der Wert fehlt (Eigenschaft=), wird die Eigenschaft gelöscht.\n"
+"        Wenn die Eigenschaft ein Link/Multilink ist, werden die verlinkten\n"
+"        Einträge als kommagetrennte ID-Nummern angegeben (\"1,2,3\").\n"
+"        "
+
+#: ../roundup/admin.py:623
+msgid ""
+"Usage: find classname propname=value ...\n"
+"        Find the nodes of the given class with a given link property value.\n"
+"\n"
+"        Find the nodes of the given class with a given link property value.\n"
+"        The value may be either the nodeid of the linked node, or its key\n"
+"        value.\n"
+"        "
+msgstr ""
+"Verwendung: find Klassenname Eigenschaft=Wert ...\n"
+"        Findet Einträge, welche die angegebene Verlinkung aufweisen.\n"
+"\n"
+"        Findet sämtliche Einträge einer Klasse, bei welchen die Link-\n"
+"        Eigenschaft den angegebenen Wert enthält. Der Wert kann entweder\n"
+"        als ID oder als Bezeichner (\"msg23\") spezifiziert werden.\n"
+"        "
+
+# ../roundup/admin.py:631 :669 :822 :834 :888
+#: ../roundup/admin.py:674 ../roundup/admin.py:827 ../roundup/admin.py:839
+#: ../roundup/admin.py:893
+#, python-format
+msgid "%(classname)s has no property \"%(propname)s\""
+msgstr "Die Klasse \"%(classname)s\" hat keine Eigenschaft \"%(propname)s\""
+
+#: ../roundup/admin.py:681
+msgid ""
+"Usage: specification classname\n"
+"        Show the properties for a classname.\n"
+"\n"
+"        This lists the properties for a given class.\n"
+"        "
+msgstr ""
+"Verwendung: specification Klassenname\n"
+"        Gibt die Spezifikation der Klasseneigenschaften aus.\n"
+"\n"
+"        Listet sämtliche Eigenschaften der Klasse auf.\n"
+"        "
+
+#: ../roundup/admin.py:696
+#, python-format
+msgid "%(key)s: %(value)s (key property)"
+msgstr "%(key)s: %(value)s (Schlüsseleigenschaft)"
+
+#: ../roundup/admin.py:698
+#, python-format
+msgid "%(key)s: %(value)s"
+msgstr "%(key)s: %(value)s"
+
+#: ../roundup/admin.py:701
+msgid ""
+"Usage: display designator[,designator]*\n"
+"        Show the property values for the given node(s).\n"
+"\n"
+"        This lists the properties and their associated values for the given\n"
+"        node.\n"
+"        "
+msgstr ""
+"Verwendung: display Bezeichner[,Bezeichner]*\n"
+"        Zeigt alle Eigenschaften eines oder mehrerer Eintrage an.\n"
+"\n"
+"        Der Befehl listet sämtliche Eigenschaften und Ihre Werte des\n"
+"        Eintrages an.\n"
+"        "
+
+#: ../roundup/admin.py:725
+#, python-format
+msgid "%(key)s: %(value)r"
+msgstr "%(key)s: %(value)r"
+
+#: ../roundup/admin.py:728
+msgid ""
+"Usage: create classname property=value ...\n"
+"        Create a new entry of a given class.\n"
+"\n"
+"        This creates a new entry of the given class using the property\n"
+"        name=value arguments provided on the command line after the \"create"
+"\"\n"
+"        command.\n"
+"        "
+msgstr ""
+"Verwendung: create Klassenname Eigenschaft=Wert ...\n"
+"        Erstellt einen neuen Eintrag der angegebenen Klasse.\n"
+"\n"
+"        Ein neuer Eintrag der Klasse wird erstellt und die Eigenschaften\n"
+"        werden mit den Werten initialisiert\n"
+"        "
+
+#: ../roundup/admin.py:755
+#, python-format
+msgid "%(propname)s (Password): "
+msgstr "%(propname)s (Passwort):"
+
+#: ../roundup/admin.py:757
+#, python-format
+msgid "   %(propname)s (Again): "
+msgstr "   %(propname)s (Wiederholen):"
+
+#: ../roundup/admin.py:759
+msgid "Sorry, try again..."
+msgstr "Bitte erneut versuchen..."
+
+#: ../roundup/admin.py:763
+#, python-format
+msgid "%(propname)s (%(proptype)s): "
+msgstr "%(propname)s (%(proptype)s): "
+
+#: ../roundup/admin.py:781
+#, python-format
+msgid "you must provide the \"%(propname)s\" property."
+msgstr "Sie müssen die Eigenschaft \"%(propname)s\" angeben."
+
+#: ../roundup/admin.py:792
+msgid ""
+"Usage: list classname [property]\n"
+"        List the instances of a class.\n"
+"\n"
+"        Lists all instances of the given class. If the property is not\n"
+"        specified, the  \"label\" property is used. The label property is\n"
+"        tried in order: the key, \"name\", \"title\" and then the first\n"
+"        property, alphabetically.\n"
+"\n"
+"        With -c, -S or -s print a list of item id's if no property\n"
+"        specified.  If property specified, print list of that property\n"
+"        for every class instance.\n"
+"        "
+msgstr ""
+"Usage: list Klassenname [Eigenschaft]\n"
+"        Listet sämtliche Einträge einer Klasse auf.\n"
+"\n"
+"        Es werden sämtliche Einträge der Klasse ausgegeben. Wird keine\n"
+"        Eigenschaft angegeben, so wird ein Bezeichner verwendet. Falls ein\n"
+"        Schlüsselfeld existiert, wird dieses ausgegeben, sonst ein Feld "
+"namens \n"
+"        \"name\" oder \"title\". Falls auch diese Felder nicht existieren, "
+"wird \n"
+"        erste Feld alphabetisch sortiert angezeigt.\n"
+"\n"
+"        Mit den Optionen -c, -S or -s wird eine Liste von ID's ausgegeben,\n"
+"        falls keine Eigenschaft angegeben wird. Sonst werden die Werte\n"
+"        dieser Eigenschaften sämtlicher Instanzen dieser Klasse "
+"aufgelistet.\n"
+"        "
+
+#: ../roundup/admin.py:805
+msgid "Too many arguments supplied"
+msgstr "Sie haben zuviele Argumente übergeben"
+
+#: ../roundup/admin.py:841
+#, python-format
+msgid "%(nodeid)4s: %(value)s"
+msgstr "%(nodeid)4s: %(value)s"
+
+#: ../roundup/admin.py:845
+msgid ""
+"Usage: table classname [property[,property]*]\n"
+"        List the instances of a class in tabular form.\n"
+"\n"
+"        Lists all instances of the given class. If the properties are not\n"
+"        specified, all properties are displayed. By default, the column\n"
+"        widths are the width of the largest value. The width may be\n"
+"        explicitly defined by defining the property as \"name:width\".\n"
+"        For example::\n"
+"\n"
+"          roundup> table priority id,name:10\n"
+"          Id Name\n"
+"          1  fatal-bug\n"
+"          2  bug\n"
+"          3  usability\n"
+"          4  feature\n"
+"\n"
+"        Also to make the width of the column the width of the label,\n"
+"        leave a trailing : without a width on the property. For example::\n"
+"\n"
+"          roundup> table priority id,name:\n"
+"          Id Name\n"
+"          1  fata\n"
+"          2  bug\n"
+"          3  usab\n"
+"          4  feat\n"
+"\n"
+"        will result in a the 4 character wide \"Name\" column.\n"
+"        "
+msgstr ""
+"Verwendung: table Klassenname [Eigenschaft[,Eigenschaft]*]\n"
+"        Listet die Einträge einer Klasse in tabelarischer Form.\n"
+"\n"
+"        Gibt eine Liste sämtlicher Instanzen einer Klasse aus.\n"
+"        Werden die Eigenschaften nicht explizit angegeben, so werden\n"
+"        alle angezeigt. Die Spaltenbreite wird automatisch nach dem \n"
+"        grössten Wert jeder Spalte berechnet, oder sie kann explizit\n"
+"        angegeben als \"Eigenschaft:Breite\"\n"
+"        Beispiel:\n"
+"\n"
+"          roundup> table priority id,name:10\n"
+"          Id Name\n"
+"          1  fatal-bug\n"
+"          2  bug\n"
+"          3  usability\n"
+"          4  feature\n"
+"\n"
+"        Um die Spaltenbreite auf die Grösse des Spaltentitels zu "
+"bechränken,\n"
+"        lassen Sie die Breitenangabe hinter dem : weg.\n"
+"        Beispiel:\n"
+"\n"
+"          roundup> table priority id,name:\n"
+"          Id Name\n"
+"          1  fata\n"
+"          2  bug\n"
+"          3  usab\n"
+"          4  feat\n"
+"\n"
+"        "
+
+#: ../roundup/admin.py:889
+#, python-format
+msgid "\"%(spec)s\" not name:width"
+msgstr "\"%(spec)s\" entspricht nicht dem Format Eigenschaft:Breite"
+
+#: ../roundup/admin.py:939
+msgid ""
+"Usage: history designator\n"
+"        Show the history entries of a designator.\n"
+"\n"
+"        Lists the journal entries for the node identified by the "
+"designator.\n"
+"        "
+msgstr ""
+"Verwendung: history Bezeichner\n"
+"        Zeigt den Verlauf eines Eintrages an.\n"
+"\n"
+"        Listet das Bearbeitungs-Journal des Eintrages mit dem angegebenen\n"
+"        Bezeichner auf.\n"
+"        "
+
+#: ../roundup/admin.py:960
+msgid ""
+"Usage: commit\n"
+"        Commit changes made to the database during an interactive session.\n"
+"\n"
+"        The changes made during an interactive session are not\n"
+"        automatically written to the database - they must be committed\n"
+"        using this command.\n"
+"\n"
+"        One-off commands on the command-line are automatically committed if\n"
+"        they are successful.\n"
+"        "
+msgstr ""
+"Verwendung: commit\n"
+"        Speichern der Datenbank-Änderungen.\n"
+"\n"
+"        Falls die Datenbank Transaktionen unterstützt, werden Änderungen\n"
+"        während einer Bearbeitungs-Session erst nach einem \"commit\" an "
+"die\n"
+"        Datenbank übermittelt.\n"
+"\n"
+"        Einzelbefehle über die Kommandozeile werden sofort in die Datenbank\n"
+"        geschrieben.\n"
+"        "
+
+#: ../roundup/admin.py:974
+msgid ""
+"Usage: rollback\n"
+"        Undo all changes that are pending commit to the database.\n"
+"\n"
+"        The changes made during an interactive session are not\n"
+"        automatically written to the database - they must be committed\n"
+"        manually. This command undoes all those changes, so a commit\n"
+"        immediately after would make no changes to the database.\n"
+"        "
+msgstr ""
+"Verwendung: rollback\n"
+"        Sämtliche nicht gespeicherte Änderungen werden verworfen.\n"
+"\n"
+"        Falls die Datenbank Transaktionen unterstützt, werden dadurch\n"
+"        sämtliche noch nicht gespeicherte Änderungen (siehe \"commit\")\n"
+"        verworfen.\n"
+"        "
+
+#: ../roundup/admin.py:986
+msgid ""
+"Usage: retire designator[,designator]*\n"
+"        Retire the node specified by designator.\n"
+"\n"
+"        This action indicates that a particular node is not to be retrieved\n"
+"        by the list or find commands, and its key value may be re-used.\n"
+"        "
+msgstr ""
+"Verwendung: retire Bezeichner[,Bezeichner]*\n"
+"        Verbirgt einen oder mehrere Einträge.\n"
+"\n"
+"        Das Verbergen eines Eintrages bewirkt, dass dieser bei einer Suche\n"
+"        nicht mehr angezeigt wird. Der Schlüssel des verborgenen Eintrages\n"
+"        kann zudem wiederverwendet werden.\n"
+"        "
+
+#: ../roundup/admin.py:1009
+msgid ""
+"Usage: restore designator[,designator]*\n"
+"        Restore the retired node specified by designator.\n"
+"\n"
+"        The given nodes will become available for users again.\n"
+"        "
+msgstr ""
+"Verwendung: restore Bezeichner[,Bezeichner]*\n"
+"        Ein oder mehrere verborgene Einträge werden wieder hergestellt.\n"
+"\n"
+"        Ein verborgener Eintrag wird wieder hergestellt und ist danach\n"
+"        für die Benutzer wieder sichtbar.\n"
+"        "
+
+#. grab the directory to export to
+#: ../roundup/admin.py:1031
+msgid ""
+"Usage: export [class[,class]] export_dir\n"
+"        Export the database to colon-separated-value files.\n"
+"\n"
+"        Optionally limit the export to just the names classes.\n"
+"\n"
+"        This action exports the current data from the database into\n"
+"        colon-separated-value files that are placed in the nominated\n"
+"        destination directory.\n"
+"        "
+msgstr ""
+"Verwendung: export [Klasse[,Klasse]] Exportverzeichnis\n"
+"        Exportiert die Datenbank in ein Verzeichnis mit CSV-Dateien.\n"
+"\n"
+"        Optional kann der Export auf gewisse Klassen beschränkt werden.\n"
+"\n"
+"        Die Daten werden als kommagetrennte Dateien in das angegebene\n"
+"        Exportverzeichnis geschrieben.\n"
+"        "
+
+#: ../roundup/admin.py:1084
+msgid ""
+"Usage: import import_dir\n"
+"        Import a database from the directory containing CSV files,\n"
+"        two per class to import.\n"
+"\n"
+"        The files used in the import are:\n"
+"\n"
+"        <class>.csv\n"
+"          This must define the same properties as the class (including\n"
+"          having a \"header\" line with those property names.)\n"
+"        <class>-journals.csv\n"
+"          This defines the journals for the items being imported.\n"
+"\n"
+"        The imported nodes will have the same nodeid as defined in the\n"
+"        import file, thus replacing any existing content.\n"
+"\n"
+"        The new nodes are added to the existing database - if you want to\n"
+"        create a new database using the imported data, then create a new\n"
+"        database (or, tediously, retire all the old data.)\n"
+"        "
+msgstr ""
+"Verwendung: import Importverzeichnis\n"
+"        Importiert Datensätze aus einem Verzeichnis mit CSV-Dateien\n"
+"\n"
+"        Folgende Dateien werden beim Import verwendet:\n"
+"\n"
+"        <Klasse>.csv\n"
+"          In dieser Datei sind die Daten zu den Einträgen einer Klasse.\n"
+"          Für sämtliche Eigenschaften der Klasse muss eine Spalte \n"
+"          exisitieren. In der ersten Zeile stehen die Eigenschaftsnamen.\n"
+"        <Klasse>-journals.csv\n"
+"          In dieser Datei wird der Bearbeitungs-Verlauf der Einträge\n"
+"          beschrieben.\n"
+"\n"
+"        Importierte Einträge übernehmen die ID's, welche in den Dateien\n"
+"        definiert sind. Existierende Einträge mit denselben ID's werden\n"
+"        überschrieben.\n"
+"        Die Einträge werden in die existierende Datenbank geschrieben.\n"
+"        Falls eine neue, leere Datenbank verwendet werden soll, so müssen\n"
+"        Sie diese zuerst erstellen (oder sämtliche bestehenden Inhalte \n"
+"        verbergen).\n"
+"        "
+
+#: ../roundup/admin.py:1152
+msgid ""
+"Usage: pack period | date\n"
+"\n"
+"        Remove journal entries older than a period of time specified or\n"
+"        before a certain date.\n"
+"\n"
+"        A period is specified using the suffixes \"y\", \"m\", and \"d\". "
+"The\n"
+"        suffix \"w\" (for \"week\") means 7 days.\n"
+"\n"
+"              \"3y\" means three years\n"
+"              \"2y 1m\" means two years and one month\n"
+"              \"1m 25d\" means one month and 25 days\n"
+"              \"2w 3d\" means two weeks and three days\n"
+"\n"
+"        Date format is \"YYYY-MM-DD\" eg:\n"
+"            2001-01-01\n"
+"\n"
+"        "
+msgstr ""
+"Verwendung: pack Periode | Datum\n"
+"        Entfernt den Bearbeitungsverlauf ab einem gewissen Datum.\n"
+"\n"
+"        Das Datum kann als rückläufige Periode spezifiziert werden:\n"
+"           \"y\", \"m\", and \"d\".         wobei \"w\" (Woche) für 7 Tage "
+"steht.\n"
+"\n"
+"        Beispiele:\n"
+"              \"3y\" steht für 3 Jahre\n"
+"              \"2y 1m\" steht für 2 Jahre und ein Monat\n"
+"              \"1m 25d\" steht für 1 Monat und 25 Tage\n"
+"              \"2w 3d\" steht für 2 Wochen und 3 Tage\n"
+"\n"
+"        Das Datumsformat lautet \"JJJJ-MM-TT\", z.B:\n"
+"            2001-06-27\n"
+"\n"
+"        "
+
+#: ../roundup/admin.py:1180
+msgid "Invalid format"
+msgstr "Ungültiges Format"
+
+#: ../roundup/admin.py:1190
+msgid ""
+"Usage: reindex [classname|designator]*\n"
+"        Re-generate a tracker's search indexes.\n"
+"\n"
+"        This will re-generate the search indexes for a tracker.\n"
+"        This will typically happen automatically.\n"
+"        "
+msgstr ""
+"Verwendung: reindex [Klasse|Bezeichner]*\n"
+"        Der Volltext-Index eines Trackers wird neu erstellt.\n"
+"\n"
+"        Der Volltext-Index wird neu generiert. Dieser Prozess geschieht \n"
+"        normalerweise automatisch.\n"
+"        "
+
+#: ../roundup/admin.py:1204
+#, python-format
+msgid "no such item \"%(designator)s\""
+msgstr "Der Eintrag \"%(designator)s\" existiert nicht"
+
+#: ../roundup/admin.py:1214
+msgid ""
+"Usage: security [Role name]\n"
+"        Display the Permissions available to one or all Roles.\n"
+"        "
+msgstr ""
+"Verwendung: security [Rollenname]\n"
+"        Zeigt die Berechtigungen einer oder aller Rollen an.\n"
+"        "
+
+#: ../roundup/admin.py:1222
+#, python-format
+msgid "No such Role \"%(role)s\""
+msgstr "Die Rolle \"%(role)s\" existiert nicht "
+
+#: ../roundup/admin.py:1228
+#, python-format
+msgid "New Web users get the Roles \"%(role)s\""
+msgstr "Neue Web-Benutzer erhalten die Rollen \"%(role)s\""
+
+#: ../roundup/admin.py:1230
+#, python-format
+msgid "New Web users get the Role \"%(role)s\""
+msgstr "Neue Web-Benutzer erhalten die Rolle \"%(role)s\""
+
+#: ../roundup/admin.py:1233
+#, python-format
+msgid "New Email users get the Roles \"%(role)s\""
+msgstr "Neue Email-Benutzer erhalten die Rollen \"%(role)s\""
+
+#: ../roundup/admin.py:1235
+#, python-format
+msgid "New Email users get the Role \"%(role)s\""
+msgstr "Neue Email-Benutzer erhalten die Rolle \"%(role)s\""
+
+#: ../roundup/admin.py:1238
+#, python-format
+msgid "Role \"%(name)s\":"
+msgstr "Rolle \"%(name)s\":"
+
+#: ../roundup/admin.py:1241
+#, python-format
+msgid " %(description)s (%(name)s for \"%(klass)s\" only)"
+msgstr "%(description)s (%(name)s einzig für \"%(klass)s\")"
+
+#: ../roundup/admin.py:1244
+#, python-format
+msgid " %(description)s (%(name)s)"
+msgstr " %(description)s (%(name)s)"
+
+#: ../roundup/admin.py:1273
+#, python-format
+msgid "Unknown command \"%(command)s\" (\"help commands\" for a list)"
+msgstr "Der Befehl \"%(command)s\" existiert nicht (siehe \"help commands\")"
+
+#: ../roundup/admin.py:1279
+#, python-format
+msgid "Multiple commands match \"%(command)s\": %(list)s"
+msgstr "Zur Abkürzung \"%(command)s\" passen mehrere Befehle: %(list)s"
+
+#: ../roundup/admin.py:1286
+msgid "Enter tracker home: "
+msgstr "Tracker-Verzeichnis: "
+
+# ../roundup/admin.py:1263 :1269 :1289
+#: ../roundup/admin.py:1293 ../roundup/admin.py:1299 ../roundup/admin.py:1319
+#, python-format
+msgid "Error: %(message)s"
+msgstr "Fehler: %(message)s"
+
+#: ../roundup/admin.py:1307
+#, python-format
+msgid "Error: Couldn't open tracker: %(message)s"
+msgstr "Fehler: Die Tracker-Instanz konnte nicht geöffnet werden: %(message)s"
+
+#: ../roundup/admin.py:1332
+#, python-format
+msgid ""
+"Roundup %s ready for input.\n"
+"Type \"help\" for help."
+msgstr ""
+"Roundup %s ist bereit.\n"
+"Schreiben Sie \"help\", um zur Hilfe zu gelangen."
+
+#: ../roundup/admin.py:1337
+msgid "Note: command history and editing not available"
+msgstr "Bemerkung: Befehlsverlauf/-bearbeitung nicht verfügbar"
+
+#: ../roundup/admin.py:1341
+msgid "roundup> "
+msgstr "roundup> "
+
+#: ../roundup/admin.py:1343
+msgid "exit..."
+msgstr "beenden..."
+
+#: ../roundup/admin.py:1353
+msgid "There are unsaved changes. Commit them (y/N)? "
+msgstr "Es gibt noch ungespeicherte Änderungen. Änderungen speichern (y/N)?"
+
+#: ../roundup/backends/back_anydbm.py:2054
+#, python-format
+msgid "WARNING: invalid date tuple %r"
+msgstr "WARNUNG: ungültiges Datums-Tupel %r"
+
+#: ../roundup/backends/rdbms_common.py:1425
+msgid "create"
+msgstr "erstellt"
+
+#: ../roundup/backends/rdbms_common.py:1588
+msgid "unlink"
+msgstr "link gelöscht"
+
+#: ../roundup/backends/rdbms_common.py:1592
+msgid "link"
+msgstr "verlinkt"
+
+#: ../roundup/backends/rdbms_common.py:1702
+msgid "set"
+msgstr "geändert"
+
+#: ../roundup/backends/rdbms_common.py:1726
+msgid "retired"
+msgstr "verborgen"
+
+#: ../roundup/backends/rdbms_common.py:1756
+msgid "restored"
+msgstr "wiederhergestellt"
+
+#: ../roundup/cgi/actions.py:58
+#, python-format
+msgid "You do not have permission to %(action)s the %(classname)s class."
+msgstr ""
+"Sie haben keine Berechtigung um die Aktion %(action)s auf die Klasse%"
+"(classname)s anzuwenden."
+
+#: ../roundup/cgi/actions.py:89
+msgid "No type specified"
+msgstr "Typ nicht spezifiziert"
+
+#: ../roundup/cgi/actions.py:91
+msgid "No ID entered"
+msgstr "Keine ID spezifiziert"
+
+#: ../roundup/cgi/actions.py:97
+#, python-format
+msgid "\"%(input)s\" is not an ID (%(classname)s ID required)"
+msgstr "\"%(input)s\" ist keine ID (%(classname)s ID wird erwartet)"
+
+#: ../roundup/cgi/actions.py:117
+msgid "You may not retire the admin or anonymous user"
+msgstr "Sie können den Administrator oder den Gast-Benutzer nicht löschen"
+
+#: ../roundup/cgi/actions.py:124
+#, python-format
+msgid "%(classname)s %(itemid)s has been retired"
+msgstr "%(classname)s %(itemid)s wurde gelöscht"
+
+#: ../roundup/cgi/actions.py:279
+#, python-format
+msgid "Not enough values on line %(line)s"
+msgstr "Nicht genügend Werte auf Zeile %(line)s"
+
+#: ../roundup/cgi/actions.py:326
+msgid "Items edited OK"
+msgstr "Die Einträge wurden aktualisiert"
+
+#: ../roundup/cgi/actions.py:386
+#, python-format
+msgid "%(class)s %(id)s %(properties)s edited ok"
+msgstr "Eigenschaft \"%(properties)s\" bei \"%(class)s %(id)s\" bearbeitet"
+
+#: ../roundup/cgi/actions.py:389
+#, python-format
+msgid "%(class)s %(id)s - nothing changed"
+msgstr "%(class)s %(id)s - keine Änderungen"
+
+#: ../roundup/cgi/actions.py:401
+#, python-format
+msgid "%(class)s %(id)s created"
+msgstr "Der Eintrag \"%(class)s %(id)s\" wurde erstellt"
+
+#: ../roundup/cgi/actions.py:433
+#, python-format
+msgid "You do not have permission to edit %(class)s"
+msgstr ""
+"Sie haben keine Berechtigung die Einträge der Klasse \"%(class)s\" zu "
+"bearbeiten"
+
+#: ../roundup/cgi/actions.py:445
+#, python-format
+msgid "You do not have permission to create %(class)s"
+msgstr ""
+"Sie haben keine Berechtigung um Einträge der Klasse \"%(class)s\" zu "
+"erstellen"
+
+#: ../roundup/cgi/actions.py:468
+msgid "You do not have permission to edit user roles"
+msgstr "Sie haben keine Berechtigung Benutzer-Rollen zu ändern"
+
+#: ../roundup/cgi/actions.py:530
+#, python-format
+msgid "Edit Error: %s"
+msgstr "Fehler bei der Bearbeitung: %s"
+
+# ../roundup/cgi/actions.py:546 :556
+#: ../roundup/cgi/actions.py:561 ../roundup/cgi/actions.py:572
+#: ../roundup/cgi/actions.py:743 ../roundup/cgi/actions.py:762
+#, python-format
+msgid "Error: %s"
+msgstr "Fehler: %s"
+
+#: ../roundup/cgi/actions.py:598
+msgid ""
+"Invalid One Time Key!\n"
+"(a Mozilla bug may cause this message to show up erroneously, please check "
+"your email)"
+msgstr ""
+"Ungültiger Authentifizierungscode!\n"
+"(Ein Fehler in Mozilla kann diese Meldung hervorrufen, bitte prüfen Sie Ihr "
+"Email-Konto)"
+
+#: ../roundup/cgi/actions.py:640
+#, python-format
+msgid "Password reset and email sent to %s"
+msgstr "Ihr Passwort wurde zurückgesetzt und per Email an %s versandt"
+
+#: ../roundup/cgi/actions.py:649
+msgid "Unknown username"
+msgstr "Benutzername unbekannt"
+
+#: ../roundup/cgi/actions.py:657
+msgid "Unknown email address"
+msgstr "Email-Adresse unbekannt"
+
+#: ../roundup/cgi/actions.py:662
+msgid "You need to specify a username or address"
+msgstr "Sie müssen einen Benutzernamen oder eine Email-Adresse angeben"
+
+#: ../roundup/cgi/actions.py:687
+#, python-format
+msgid "Email sent to %s"
+msgstr "Eine Email wurde an %s versandt"
+
+#: ../roundup/cgi/actions.py:706
+msgid "You are now registered, welcome!"
+msgstr "Sie sind nun registriert. Willkommen!"
+
+#: ../roundup/cgi/actions.py:751
+msgid "It is not permitted to supply roles at registration."
+msgstr "Bei der Registrierung dürfen keine Rollen angegeben werden"
+
+#: ../roundup/cgi/actions.py:834
+msgid "You are logged out"
+msgstr "Sie wurden vom System abgemeldet"
+
+#: ../roundup/cgi/actions.py:845
+msgid "Username required"
+msgstr "Benutzername notwendig"
+
+#: ../roundup/cgi/actions.py:873 ../roundup/cgi/actions.py:877
+msgid "Invalid login"
+msgstr "Ungültiges Login"
+
+#: ../roundup/cgi/actions.py:883
+msgid "You do not have permission to login"
+msgstr "Sie haben keine Berechtigung sich anzumelden"
+
+#: ../roundup/cgi/cgitb.py:49
+#, python-format
+msgid ""
+"<h1>Templating Error</h1>\n"
+"<p><b>%(exc_type)s</b>: %(exc_value)s</p>\n"
+"<p class=\"help\">Debugging information follows</p>"
+msgstr ""
+"<h1>Templating Fehler</h1>\n"
+"<p><b>%(exc_type)s</b>: %(exc_value)s</p>\n"
+"<p class=\"help\">Es folgen Informationen zum Fehler</p>"
+
+#: ../roundup/cgi/cgitb.py:64
+#, python-format
+msgid "<li>\"%(name)s\" (%(info)s)</li>"
+msgstr "<li>\"%(name)s\" (%(info)s)</li>"
+
+#: ../roundup/cgi/cgitb.py:67
+#, python-format
+msgid "<li>Looking for \"%(name)s\", current path:<ol>%(path)s</ol></li>"
+msgstr "<li>Looking for \"%(name)s\", current path:<ol>%(path)s</ol></li>"
+
+#: ../roundup/cgi/cgitb.py:71
+#, python-format
+msgid "<li>In %s</li>"
+msgstr "<li>In %s</li>"
+
+#: ../roundup/cgi/cgitb.py:76
+#, python-format
+msgid "A problem occurred in your template \"%s\"."
+msgstr "Ein Problem ist im Template \"%s\" aufgetreten."
+
+#: ../roundup/cgi/cgitb.py:84
+#, python-format
+msgid ""
+"\n"
+"<li>While evaluating the %(info)r expression on line %(line)d\n"
+"<table class=\"otherinfo\" style=\"font-size: 90%%\">\n"
+" <tr><th colspan=\"2\" class=\"header\">Current variables:</th></tr>\n"
+" %(globals)s\n"
+" %(locals)s\n"
+"</table></li>\n"
+msgstr ""
+"\n"
+"<li>Beim Ausführen von %(info)r auf Zeile %(line)d\n"
+"<table class=\"otherinfo\" style=\"font-size: 90%%\">\n"
+" <tr><th colspan=\"2\" class=\"header\">Aktuelle Variablen:</th></tr>\n"
+" %(globals)s\n"
+" %(locals)s\n"
+"</table></li>\n"
+
+#: ../roundup/cgi/cgitb.py:103
+msgid "Full traceback:"
+msgstr "Vollständiger Traceback:"
+
+#: ../roundup/cgi/cgitb.py:116
+#, python-format
+msgid "<font size=+1><strong>%(exc_type)s</strong>: %(exc_value)s</font>"
+msgstr "<font size=+1><strong>%(exc_type)s</strong>: %(exc_value)s</font>"
+
+#: ../roundup/cgi/cgitb.py:120
+msgid ""
+"<p>A problem occurred while running a Python script. Here is the sequence of "
+"function calls leading up to the error, with the most recent (innermost) "
+"call first. The exception attributes are:"
+msgstr ""
+"<p>Ein Problem trat auf, als ein Python-Script ausgeführt wurde. Hier sehen "
+"Sie die Aufrufe, welche zu einem Fehler führten. Der letzten Aufruf erscheint "
+"zuoberst. Der Fehler hat folgende Attribute: "
+
+#: ../roundup/cgi/cgitb.py:129
+msgid "&lt;file is None - probably inside <tt>eval</tt> or <tt>exec</tt>&gt;"
+msgstr "&lt;file ist None - Wahrscheinlich in einem <tt>eval</tt> oder einem "
+"<tt>exec</tt>&gt;"
+
+#: ../roundup/cgi/cgitb.py:138
+#, python-format
+msgid "in <strong>%s</strong>"
+msgstr "in <strong>%s</strong>"
+
+# ../roundup/cgi/cgitb.py:145 :151
+#: ../roundup/cgi/cgitb.py:172 ../roundup/cgi/cgitb.py:178
+msgid "<em>undefined</em>"
+msgstr "<em>nicht definiert</em>"
+
+#: ../roundup/cgi/client.py:291
+msgid "Form Error: "
+msgstr "Formular-Fehler: "
+
+#: ../roundup/cgi/client.py:344
+#, python-format
+msgid "Unrecognized charset: %r"
+msgstr "Charset-Codierung nicht erkannt: %r"
+
+#: ../roundup/cgi/client.py:446
+msgid "Anonymous users are not allowed to use the web interface"
+msgstr ""
+"Gast-Benutzer haben nicht die Berechtigung, das Web-Interface zu benutzen."
+
+#: ../roundup/cgi/client.py:597
+msgid "You are not allowed to view this file."
+msgstr "Sie haben nicht die Berechtigung, diese Seite anzuzeigen."
+
+#: ../roundup/cgi/client.py:689
+#, python-format
+msgid "%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n"
+msgstr "%(starttag)sBenötigte Zeit: %(seconds)fs%(endtag)s\n"
+
+#: ../roundup/cgi/client.py:693
+#, python-format
+msgid ""
+"%(starttag)sCache hits: %(cache_hits)d, misses %(cache_misses)d. Loading "
+"items: %(get_items)f secs. Filtering: %(filtering)f secs.%(endtag)s\n"
+msgstr ""
+"%(starttag)sCache benutzt: %(cache_hits)d, verfehlt: %(cache_misses)d. " 
+"Einträge laden: %(get_items)fs; filtern: %(filtering)fs.%(endtag)s\n"
+
+#: ../roundup/cgi/form_parser.py:283
+#, python-format
+msgid "link \"%(key)s\" value \"%(value)s\" not a designator"
+msgstr ""
+"Der Wert \"%(value)s\" ist kein gültiger Bezeichner für die Verknüpfung \"%"
+"(key)s\""
+
+#: ../roundup/cgi/form_parser.py:290
+#, python-format
+msgid "%(class)s %(property)s is not a link or multilink property"
+msgstr "%(class)s %(property)s ist weder ein Link noch ein Mehrfachlink"
+
+#: ../roundup/cgi/form_parser.py:312
+#, python-format
+msgid ""
+"You have submitted a %(action)s action for the property \"%(property)s\" "
+"which doesn't exist"
+msgstr "Die Aktion %(action)s gilt nicht für die Eigenschaft \"%(property)s\" "
+
+# ../roundup/cgi/form_parser.py:331 :357
+#: ../roundup/cgi/form_parser.py:331 ../roundup/cgi/form_parser.py:357
+#, python-format
+msgid "You have submitted more than one value for the %s property"
+msgstr "Sie haben mehr als einen Wert für die Eigenschaft \"%s\" übermittelt"
+
+# ../roundup/cgi/form_parser.py:354 :360
+#: ../roundup/cgi/form_parser.py:354 ../roundup/cgi/form_parser.py:360
+msgid "Password and confirmation text do not match"
+msgstr "Die beiden Passwort-Felder stimmen nicht überein"
+
+#: ../roundup/cgi/form_parser.py:395
+#, python-format
+msgid "property \"%(propname)s\": \"%(value)s\" not currently in list"
+msgstr "Der Wert \"%(value)s\" ist nicht in der Liste für \"%(propname)s\""
+
+#: ../roundup/cgi/form_parser.py:509
+#, python-format
+msgid "Required %(class)s property %(property)s not supplied"
+msgid_plural "Required %(class)s properties %(property)s not supplied"
+msgstr[0] ""
+"Die Eigenschaft \"%(property)s\" muss für die Klasse \"%(class)s\" angegeben "
+"werden"
+msgstr[1] ""
+"Die Eigenschaften \"%(property)s\" müssen für die Klasse \"%(class)s\" "
+"angegeben werden"
+
+#: ../roundup/cgi/form_parser.py:529
+msgid "File is empty"
+msgstr "Die ausgewählte Datei ist leer"
+
+#: ../roundup/cgi/templating.py:68
+#, python-format
+msgid "You are not allowed to %(action)s items of class %(class)s"
+msgstr ""
+"Sie haben keine Berechtigung, um die Aktion  \"%(action)s\" auf Einträge der "
+"Klasse \"%(class)s\" anzuwenden"
+
+#: ../roundup/cgi/templating.py:612
+msgid "(list)"
+msgstr "(Liste)"
+
+#: ../roundup/cgi/templating.py:646
+msgid "Submit New Entry"
+msgstr "Eintrag speichern"
+
+#: ../roundup/cgi/templating.py:656
+msgid "New node - no history"
+msgstr "Neuer Eintrag - Noch kein Verlauf"
+
+#: ../roundup/cgi/templating.py:756
+msgid "Submit Changes"
+msgstr "Speichern"
+
+#: ../roundup/cgi/templating.py:837
+msgid "<em>The indicated property no longer exists</em>"
+msgstr "<em>Die gewählte Eigenschaft existiert nicht mehr</em>"
+
+#: ../roundup/cgi/templating.py:838
+#, python-format
+msgid "<em>%s: %s</em>\n"
+msgstr "<em>%s: %s</em>\n"
+
+#: ../roundup/cgi/templating.py:851
+#, python-format
+msgid "The linked class %(classname)s no longer exists"
+msgstr "Die verlinkte Klasse \"%(classname)s\" existiert nicht mehr"
+
+# ../roundup/cgi/templating.py:905 :926
+#: ../roundup/cgi/templating.py:884 ../roundup/cgi/templating.py:905
+msgid "<strike>The linked node no longer exists</strike>"
+msgstr "<strike>Der verknüpfte Eintrag existiert nicht mehr</strike>"
+
+#: ../roundup/cgi/templating.py:944
+msgid "No"
+msgstr "Nein"
+
+#: ../roundup/cgi/templating.py:944
+msgid "Yes"
+msgstr "Ja"
+
+#: ../roundup/cgi/templating.py:955
+#, python-format
+msgid "%s: (no value)"
+msgstr "%s: (kein Wert)"
+
+#: ../roundup/cgi/templating.py:967
+msgid ""
+"<strong><em>This event is not handled by the history display!</em></strong>"
+msgstr ""
+"<strong><em>Ereignis kann nicht im Verlauf angezeigt werden!</em></strong>"
+
+#: ../roundup/cgi/templating.py:979
+msgid "<tr><td colspan=4><strong>Note:</strong></td></tr>"
+msgstr "<tr><td colspan=4><strong>Notiz:</strong></td></tr>"
+
+#: ../roundup/cgi/templating.py:988
+msgid "History"
+msgstr "Verlauf"
+
+#: ../roundup/cgi/templating.py:990
+msgid "<th>Date</th>"
+msgstr "<th>Datum</th>"
+
+#: ../roundup/cgi/templating.py:991
+msgid "<th>User</th>"
+msgstr "<th>Benutzer</th>"
+
+#: ../roundup/cgi/templating.py:992
+msgid "<th>Action</th>"
+msgstr "<th>Aktion</th>"
+
+#: ../roundup/cgi/templating.py:993
+msgid "<th>Args</th>"
+msgstr "<th>Argumente</th>"
+
+#: ../roundup/cgi/templating.py:1234
+msgid "*encrypted*"
+msgstr "*verschlüsselt*"
+
+#: ../roundup/cgi/templating.py:1412
+msgid ""
+"default value for DateHTMLProperty must be either DateHTMLProperty or string "
+"date representation."
+msgstr ""
+"Der voreingestellte Wert einer DateHTML-Eigenschaft muss entweder ein\n"
+"DateHTML Objekt sein oder ein Datum repräsentieren."
+
+#: ../roundup/cgi/templating.py:1600
+#, python-format
+msgid "<option %svalue=\"-1\">- no selection -</option>"
+msgstr "<option %svalue=\"-1\">- keine Auswahl -</option>"
+
+#: ../roundup/date.py:180
+#, python-format
+msgid "Not a date spec: %s"
+msgstr "Kein gültiges Datum: %s"
+
+#: ../roundup/date.py:231
+#, python-format
+msgid "%r not a date spec (%s)"
+msgstr "%r ist kein gültiges Datum (%s)"
+
+#: ../roundup/date.py:522
+msgid ""
+"Not an interval spec: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS] [date spec]"
+msgstr ""
+"Fehler im Zeitperioden-Format: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS] [date "
+"spec]"
+
+#: ../roundup/date.py:541
+msgid "Not an interval spec: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS]"
+msgstr ""
+"Fehler im Intervall-Format: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS] [date "
+"spec]"
+
+#: ../roundup/date.py:678
+#, python-format
+msgid "%(number)s year"
+msgid_plural "%(number)s years"
+msgstr[0] "%(number)s Jahr"
+msgstr[1] "%(number)s Jahren"
+
+#: ../roundup/date.py:682
+#, python-format
+msgid "%(number)s month"
+msgid_plural "%(number)s months"
+msgstr[0] "%(number)s Monat"
+msgstr[1] "%(number)s Monaten"
+
+#: ../roundup/date.py:686
+#, python-format
+msgid "%(number)s week"
+msgid_plural "%(number)s weeks"
+msgstr[0] "%(number)s Woche"
+msgstr[1] "%(number)s Wochen"
+
+#: ../roundup/date.py:690
+#, python-format
+msgid "%(number)s day"
+msgid_plural "%(number)s days"
+msgstr[0] "%(number)s Tag"
+msgstr[1] "%(number)s Tagen"
+
+#: ../roundup/date.py:694
+msgid "tomorrow"
+msgstr "Morgen"
+
+#: ../roundup/date.py:696
+msgid "yesterday"
+msgstr "Gestern"
+
+#: ../roundup/date.py:699
+#, python-format
+msgid "%(number)s hour"
+msgid_plural "%(number)s hours"
+msgstr[0] "%(number)s Stunde"
+msgstr[1] "%(number)s Stunden"
+
+#: ../roundup/date.py:703
+msgid "an hour"
+msgstr "eine Stunde"
+
+#: ../roundup/date.py:705
+msgid "1 1/2 hours"
+msgstr "1 1/2 Stunden"
+
+#: ../roundup/date.py:707
+#, python-format
+msgid "1 %(number)s/4 hours"
+msgid_plural "1 %(number)s/4 hours"
+msgstr[0] "1 %(number)s/4 Stunden"
+msgstr[1] "1 %(number)s/4 Stunden"
+
+#: ../roundup/date.py:711
+msgid "in a moment"
+msgstr "in Kürze"
+
+#: ../roundup/date.py:713
+msgid "just now"
+msgstr "Soeben"
+
+#: ../roundup/date.py:716
+msgid "1 minute"
+msgstr "1 Minute"
+
+#: ../roundup/date.py:719
+#, python-format
+msgid "%(number)s minute"
+msgid_plural "%(number)s minutes"
+msgstr[0] "%(number)s Minute"
+msgstr[1] "%(number)s Minuten"
+
+#: ../roundup/date.py:722
+msgid "1/2 an hour"
+msgstr "1/2 Stunde"
+
+#: ../roundup/date.py:724
+#, python-format
+msgid "%(number)s/4 hour"
+msgid_plural "%(number)s/4 hours"
+msgstr[0] "%(number)s/4 Stunden"
+msgstr[1] "%(number)s/4 Stunden"
+
+#: ../roundup/date.py:728
+#, python-format
+msgid "%s ago"
+msgstr "vor %s"
+
+#: ../roundup/date.py:730
+#, python-format
+msgid "in %s"
+msgstr "in %s"
+
+#: ../roundup/init.py:132
+#, python-format
+msgid ""
+"WARNING: directory '%s'\n"
+"\tcontains old-style template - ignored"
+msgstr ""
+"WARNUNG: Das Verzeichnis '%s'\n"
+"\tenthält Templates im alten Format, die ignoriert werden."
+
+#: ../roundup/roundupdb.py:141
+msgid "files"
+msgstr "Dateien"
+
+#: ../roundup/roundupdb.py:141
+msgid "messages"
+msgstr "Meldungen"
+
+#: ../roundup/roundupdb.py:141
+msgid "nosy"
+msgstr "Interessenten"
+
+#: ../roundup/roundupdb.py:141
+msgid "superseder"
+msgstr "Übergeordnet"
+
+#: ../roundup/roundupdb.py:141
+msgid "title"
+msgstr "Titel"
+
+#: ../roundup/roundupdb.py:142
+msgid "assignedto"
+msgstr "Zugewiesen"
+
+#: ../roundup/roundupdb.py:142
+msgid "priority"
+msgstr "Prioriät"
+
+#: ../roundup/roundupdb.py:142
+msgid "status"
+msgstr "Status"
+
+#: ../roundup/roundupdb.py:142
+msgid "topic"
+msgstr "Thema"
+
+#: ../roundup/roundupdb.py:145
+msgid "activity"
+msgstr "Aktivität"
+
+#. following properties are common for all hyperdb classes
+#. they are listed here to keep things in one place
+#: ../roundup/roundupdb.py:145
+msgid "actor"
+msgstr "Akteur"
+
+#: ../roundup/roundupdb.py:145
+msgid "creation"
+msgstr "Erstellungsdatum"
+
+#: ../roundup/roundupdb.py:145
+msgid "creator"
+msgstr "Ersteller"
+
+#: ../roundup/scripts/roundup_demo.py:32
+#, python-format
+msgid "Enter directory path to create demo tracker [%s]: "
+msgstr "Verzeichnis für Tracker-Demo eingeben [%s]: "
+
+#: ../roundup/scripts/roundup_gettext.py:22
+#, python-format
+msgid "Usage: %(program)s <tracker home>"
+msgstr "Verwendung: %(program)s <Tracker Verzeichnis>"
+
+#: ../roundup/scripts/roundup_gettext.py:37
+#, python-format
+msgid "No tracker templates found in directory %s"
+msgstr "Keine Tracker-Vorlage gefunden im Verzeichnis %s"
+
+#: ../roundup/scripts/roundup_mailgw.py:36
+#, python-format
+msgid ""
+"Usage: %(program)s [-v] [-c] [[-C class] -S field=value]* <instance home> "
+"[method]\n"
+"\n"
+"Options:\n"
+" -v: print version and exit\n"
+" -c: default class of item to create (else the tracker's "
+"MAIL_DEFAULT_CLASS)\n"
+" -C / -S: see below\n"
+"\n"
+"The roundup mail gateway may be called in one of four ways:\n"
+" . with an instance home as the only argument,\n"
+" . with both an instance home and a mail spool file,\n"
+" . with both an instance home and a POP/APOP server account, or\n"
+" . with both an instance home and a IMAP/IMAPS server account.\n"
+"\n"
+"It also supports optional -C and -S arguments that allows you to set a\n"
+"fields for a class created by the roundup-mailgw. The default class if\n"
+"not specified is msg, but the other classes: issue, file, user can\n"
+"also be used. The -S or --set options uses the same\n"
+"property=value[;property=value] notation accepted by the command line\n"
+"roundup command or the commands that can be given on the Subject line\n"
+"of an email message.\n"
+"\n"
+"It can let you set the type of the message on a per email address basis.\n"
+"\n"
+"PIPE:\n"
+" In the first case, the mail gateway reads a single message from the\n"
+" standard input and submits the message to the roundup.mailgw module.\n"
+"\n"
+"UNIX mailbox:\n"
+" In the second case, the gateway reads all messages from the mail spool\n"
+" file and submits each in turn to the roundup.mailgw module. The file is\n"
+" emptied once all messages have been successfully handled. The file is\n"
+" specified as:\n"
+"   mailbox /path/to/mailbox\n"
+"\n"
+"POP:\n"
+" In the third case, the gateway reads all messages from the POP server\n"
+" specified and submits each in turn to the roundup.mailgw module. The\n"
+" server is specified as:\n"
+"    pop username:password at server\n"
+" The username and password may be omitted:\n"
+"    pop username at server\n"
+"    pop server\n"
+" are both valid. The username and/or password will be prompted for if\n"
+" not supplied on the command-line.\n"
+"\n"
+"APOP:\n"
+" Same as POP, but using Authenticated POP:\n"
+"    apop username:password at server\n"
+"\n"
+"IMAP:\n"
+" Connect to an IMAP server. This supports the same notation as that of\n"
+" POP mail.\n"
+"    imap username:password at server\n"
+" It also allows you to specify a specific mailbox other than INBOX using\n"
+" this format:\n"
+"    imap username:password at server mailbox\n"
+"\n"
+"IMAPS:\n"
+" Connect to an IMAP server over ssl.\n"
+" This supports the same notation as IMAP.\n"
+"    imaps username:password at server [mailbox]\n"
+"\n"
+msgstr ""
+"Verwendung: %(program)s [-v] [[-C Klasse] -S Eigenschaft=Wert]* <Tracker-"
+"Verzeichnis> [Methode]\n"
+"\n"
+"Optionen:\n"
+" -v: Versionsnummer ausgeben und beenden\n"
+" -c: Vorgegebene Klasse beim Erstellen eines Eintrages (sonst: MAIL_DEFAULT_CLASS)\n"
+" -C / -S: siehe Unten\n"
+"\n"
+"Das Roundup Mailgateway kann auf vier verschiedene Arten aufgerufen werden:\n"
+" . mit einem Tracker-Verzeichnis als einziges Argument,\n"
+" . mit einem Tracker-Verzeichnis und einer Mailbox Datei,\n"
+" . mit einem Tracker-Verzeichnis und einem POP/APOP Konto, oder\n"
+" . mit einem Tracker-Verzeichnis und einem IMAP/IMAPS Konto.\n"
+"\n"
+"Optional kann mit -C die Klasse des zu erstellenden Eintrages spezifiziert \n"
+"werden. Zudem können Sie mit -S oder --set Eigenschaften der Einträge\n"
+"als Eigenschaft=Wert[;Eigenschaft=Wert]* setzen, analog zum Roundup-\n"
+"Kommandozeilen Programm, resp. zur Syntax in der Betreffszeile einer Email.\n"
+"Voreingestellt ist die Klasse \"msg\", aber auch Klassen wie \"issue\",\n"
+"\"user\" oder \"file\" können verwendet werden.\n"
+"\n"
+"Sie können dadurch mehrere Email-Kontos für einen Tracker verwenden und\n"
+"unterschiedliche Eintragstypen aus den Meldungen erstellen.\n"
+"\n"
+"PIPE:\n"
+" Das Mail Gateway liest eine Meldung vom Standard-Input und übergibt die\n"
+" Meldung an das Modul roundup.mailgw.\n"
+"\n"
+"UNIX Mailbox:\n"
+" Die angegebene Mailbox-Datei wird ausgelesen und alle Meldungen werden\n"
+" an das Modul roundup.mailgw übergeben. Nach erfolgreicher Verarbeitung \n"
+" wird die Mail-Spool Datei geleert.\n"
+" Die Mailbox-Datei wird folgendermassen angegeben:  mailbox /pfad/zur/"
+"mailbox\n"
+"\n"
+"POP:\n"
+" Das Gateway liest alle Meldungen vom POP3-Konto und leitet sie weiter an \n"
+" das Modul roundup.mailgw. \n"
+" Das Konto wird folgendermassen angegeben:\n"
+"    pop benutzername:passwort at server\n"
+" Benutzername und Passwort können weggelassen werden:\n"
+"    pop benutzername at server\n"
+"    pop server\n"
+" In diesem Fall werden die Anmeldungs-Daten zur Laufzeit erfragt.\n"
+"\n"
+"APOP:\n"
+" Wie POP aber unter Verwendung von authentifiziertem POP:\n"
+"    apop benutzername:passwort at server\n"
+"\n"
+"IMAP:\n"
+" Verbindung mit einem IMAP-Server. Die Syntax entspricht der POP-\n"
+" Spezifikation:\n"
+"    imap benutzername:passwort at server\n"
+" Um eine andere Mailbox anstelle von \"INBOX\" zu verwenden, benutzen Sie:\n"
+"    imap benutzername:passwort at server mailbox\n"
+"\n"
+"IMAPS:\n"
+" Verbindung zu einem IMAP-Server über eine sichere SSL-Verbindung.\n"
+" Die Syntax entspricht der IMAP-Spezifikation:\n"
+"    imaps benutzername:passwort at server [mailbox]\n"
+"\n"
+
+#: ../roundup/scripts/roundup_mailgw.py:147
+msgid "Error: not enough source specification information"
+msgstr "Sie haben nicht genügend Angaben zur Mail-Quelle gemacht"
+
+#: ../roundup/scripts/roundup_mailgw.py:157
+msgid "Error: pop specification not valid"
+msgstr "Fehler: pop Optionen ungültig"
+
+#: ../roundup/scripts/roundup_mailgw.py:164
+msgid "Error: apop specification not valid"
+msgstr "Fehler: apop Optionen ungültig"
+
+#: ../roundup/scripts/roundup_mailgw.py:178
+msgid ""
+"Error: The source must be either \"mailbox\", \"pop\", \"apop\", \"imap\" or "
+"\"imaps\""
+msgstr ""
+"Fehler: Als Mail-Quelle muss \"mailbox\", \"pop\", \"apop\", \"imap\" oder "
+"\"imaps\" gewählt werden"
+
+#: ../roundup/scripts/roundup_server.py:140
+msgid ""
+"<html><head><title>Roundup trackers index</title></head>\n"
+"<body><h1>Roundup trackers index</h1><ol>\n"
+msgstr ""
+"<html><head><title>Roundup Tracker-Liste</title></head>\n"
+"<body><h1>Roundup Tracker-Liste</h1><ol>\n"
+
+#: ../roundup/scripts/roundup_server.py:242
+#, python-format
+msgid "Error: %s: %s"
+msgstr "Fehler: %s: %s"
+
+#: ../roundup/scripts/roundup_server.py:252
+msgid "WARNING: ignoring \"-g\" argument, not root"
+msgstr ""
+"WARNUNG: die Option \"-g\" wird ignoriert, da Sie nicht Administrator sind"
+
+#: ../roundup/scripts/roundup_server.py:258
+msgid "Can't change groups - no grp module"
+msgstr "Die Gruppe kann nicht gewechselt werden - das grp Modul fehlt"
+
+#: ../roundup/scripts/roundup_server.py:267
+#, python-format
+msgid "Group %(group)s doesn't exist"
+msgstr "Die Gruppe %(group)s existiert nicht"
+
+#: ../roundup/scripts/roundup_server.py:278
+msgid "Can't run as root!"
+msgstr "Dieser Prozess kann nicht unter dem Administrator (\"root\") laufen!"
+
+#: ../roundup/scripts/roundup_server.py:281
+msgid "WARNING: ignoring \"-u\" argument, not root"
+msgstr ""
+"WARNUNG: die Option \"-u\" wird ignoriert, da Sie nicht Administrator sind"
+
+#: ../roundup/scripts/roundup_server.py:286
+msgid "Can't change users - no pwd module"
+msgstr "Der Benutzer kann nicht gewechselt werden - das pwd Modul fehlt"
+
+#: ../roundup/scripts/roundup_server.py:295
+#, python-format
+msgid "User %(user)s doesn't exist"
+msgstr "Der Benutzer %(user)s existiert nicht"
+
+#: ../roundup/scripts/roundup_server.py:417
+#, python-format
+msgid "Multiprocess mode \"%s\" is not available, switching to single-process"
+msgstr "Der Multiprozess-Modus \"%s\" ist nicht verfügbar, Einprozess-Modus"
+"aktiviert"
+
+#: ../roundup/scripts/roundup_server.py:440
+#, python-format
+msgid "Unable to bind to port %s, port already in use."
+msgstr "Start des Servers auf Port %s schlug fehl. Port bereits verwendet."
+
+#: ../roundup/scripts/roundup_server.py:507
+msgid ""
+" -c <Command>  Windows Service options.\n"
+"               If you want to run the server as a Windows Service, you\n"
+"               must use configuration file to specify tracker homes.\n"
+"               Logfile option is required to run Roundup Tracker service.\n"
+"               Typing \"roundup-server -c help\" shows Windows Services\n"
+"               specifics."
+msgstr ""
+" -c <Befehl>   Windows Service Optionen.\n"
+"               Um den Roundup-Server als Windows Service zu starten,\n"
+"               benutzen Sie eine Server-Konfiguration, in der die Tracker-\n"
+"               Instanzen angegeben werden.\n"
+"               Zudem müssen Sie die Logfile-Option aktivieren.\n"
+"               \"roundup-server -c help\" zeigt eine weitere Hilfe zum Thema."
+
+#: ../roundup/scripts/roundup_server.py:514
+msgid ""
+" -u <UID>      runs the Roundup web server as this UID\n"
+" -g <GID>      runs the Roundup web server as this GID\n"
+" -d <PIDfile>  run the server in the background and write the server's PID\n"
+"               to the file indicated by PIDfile. The -l option *must* be\n"
+"               specified if -d is used."
+msgstr ""
+" -u <UID>      Startet den Roundup-Server mit dieser Benutzer-Nummer\n"
+" -g <GID>      Startet den Roundup-Server mit dieser Gruppen-Nummer\n"
+" -d <PIDDatei> Startet den Server als Hintergrundprozess und schreibt\n"
+"               die Prozess-ID in die Datei PIDDatei\n"
+"               Die Option -l muss dann auch angegeben werden."
+
+#: ../roundup/scripts/roundup_server.py:521
+#, python-format
+msgid ""
+"%(message)sUsage: roundup-server [options] [name=tracker home]*\n"
+"\n"
+"Options:\n"
+" -v            print the Roundup version number and exit\n"
+" -h            print this text and exit\n"
+" -S            create or update configuration file and exit\n"
+" -C <fname>    use configuration file <fname>\n"
+" -n <name>     set the host name of the Roundup web server instance\n"
+" -p <port>     set the port to listen on (default: %(port)s)\n"
+" -l <fname>    log to the file indicated by fname instead of stderr/stdout\n"
+" -N            log client machine names instead of IP addresses (much "
+"slower)\n"
+" -t <mode>     multiprocess mode (default: %(mp_def)s).\n"
+"               Allowed values: %(mp_types)s.\n"
+"%(os_part)s\n"
+"\n"
+"Long options:\n"
+" --version          print the Roundup version number and exit\n"
+" --help             print this text and exit\n"
+" --save-config      create or update configuration file and exit\n"
+" --config <fname>   use configuration file <fname>\n"
+" All settings of the [main] section of the configuration file\n"
+" also may be specified in form --<name>=<value>\n"
+"\n"
+"Examples:\n"
+"\n"
+" roundup-server -S -C /opt/roundup/etc/roundup-server.ini \\\n"
+"    -n localhost -p 8917 -l /var/log/roundup.log \\\n"
+"    support=/var/spool/roundup-trackers/support\n"
+"\n"
+" roundup-server -C /opt/roundup/etc/roundup-server.ini\n"
+"\n"
+" roundup-server support=/var/spool/roundup-trackers/support\n"
+"\n"
+" roundup-server -d /var/run/roundup.pid -l /var/log/roundup.log \\\n"
+"    support=/var/spool/roundup-trackers/support\n"
+"\n"
+"Configuration file format:\n"
+"   Roundup Server configuration file has common .ini file format.\n"
+"   Configuration file created with 'roundup-server -S' contains\n"
+"   detailed explanations for each option.  Please see that file\n"
+"   for option descriptions.\n"
+"\n"
+"How to use \"name=tracker home\":\n"
+"   These arguments set the tracker home(s) to use. The name is how the\n"
+"   tracker is identified in the URL (it's the first part of the URL path).\n"
+"   The tracker home is the directory that was identified when you did\n"
+"   \"roundup-admin init\". You may specify any number of these name=home\n"
+"   pairs on the command-line. Make sure the name part doesn't include\n"
+"   any url-unsafe characters like spaces, as these confuse IE.\n"
+msgstr ""
+"%(message)s"
+"Benutzung: roundup-server [Optionen] [Tracker-Name=Tracker-Verzeichnis]*\n"
+"\n"
+"Optionen:\n"
+" -v            Versionsnummer ausgeben und beenden\n"
+" -h            Diese Hilfe ausgeben und beenden\n"
+" -S            Konfiguration erstellen oder aktualiseren und beenden\n"
+" -C <Datei>    Konfiguration in <Datei> verwenden\n"
+" -n            Hostname des Serverprozesses bestimmen\n"
+" -p            Port bestimmen (Voreinstellung: %(port)s)\n"
+" -l            Logdatei bestimmen (anstelle \"stderr\" / \"stdout\")\n"
+" -N            Domainnamen in der Logdatei auflösen (viel langsamer)\n"
+" -t <Modus>    Multiprozess-Modus (Voreinstellung: %(mp_def)s).\n"
+"               Verfügbare Modi: %(mp_types)s.\n"
+"%(os_part)s\n"
+"\n"
+"Lange Optionen:\n"
+" --version          Roundup Versionsnummer ausgeben und beenden\n"
+" --help             Diese Hilfe ausgeben und beenden\n"
+" --save-config      Konfiguration erstellen oder aktualiseren und beenden\n"
+" --config <fname>   Konfiguration <Datei> verwenden\n"
+" Die Einstellungen in der Sektion [main] der Konfigurationsdatei können Sie\n"
+" auch in der Form --<Name>=<Wert> angegeben.\n"
+"\n"
+"Beispiele:\n"
+"\n"
+" roundup-server -S -C /opt/roundup/etc/roundup-server.ini \\\n"
+"    -n localhost -p 8917 -l /var/log/roundup.log \\\n"
+"    support=/var/spool/roundup-trackers/support\n"
+"\n"
+" roundup-server -C /opt/roundup/etc/roundup-server.ini\n"
+"\n"
+" roundup-server support=/var/spool/roundup-trackers/support\n"
+"\n"
+" roundup-server -d /var/run/roundup.pid -l /var/log/roundup.log \\\n"
+"    support=/var/spool/roundup-trackers/support\n"
+"\n"
+"Konfigurations-Format:\n"
+"   Roundup Server benutzt das standardisierte .ini Format.\n"
+"   Konfigurationen, welche mit 'roundup-server -S' erstellt werden, \n"
+"   enthalten detaillierte Erklärungen zu jeder Option. Bitte konsultieren\n"
+"   Sie diese Datei für weitere Angaben.\n"
+"\n"
+"Tracker-Name=Tracker-Verzeichnis:\n"
+"   Gibt an, welche Tracker-Instanz(en) verwendet werden. Der Tracker-Name\n"
+"   bestimmt den URL-Pfad im Web. Das Tracker-Verzeichnis gibt an, in \n"
+"   welchem Verzeichnis die Tracker-Konfiguration gespeichert wurde.\n"
+"   Sie können mehrere Tracker-Instanzen auf der Kommandozeile angeben oder\n"
+"   alternativ die Variable TRACKER_HOMES in der roundup-server Datei \n"
+"   anpassen. \n"
+"   ACHTUNG: Der Tracker-Name darf keine Sonderzeichen enthalten, welche in \n"
+"   URLs Probleme bereiten könnten. Am besten verwenden Sie nur Buchstaben, \n"
+"   Zahlen und \"-_\".\n"
+
+#: ../roundup/scripts/roundup_server.py:669
+msgid "Instances must be name=home"
+msgstr "Instanzen müssen als Tracker-Name=Tracker-Verzeichnis angegeben werden"
+
+#: ../roundup/scripts/roundup_server.py:683
+#, python-format
+msgid "Configuration saved to %s"
+msgstr "Konfiguration in der Datei %s gespeichert"
+
+#: ../roundup/scripts/roundup_server.py:694
+msgid "Sorry, you can't run the server as a daemon on this Operating System"
+msgstr ""
+"Auf diesem Betriebssystem kann der Server nicht als Hintergrundprozess laufen"
+
+#: ../roundup/scripts/roundup_server.py:706
+#, python-format
+msgid "Roundup server started on %(HOST)s:%(PORT)s"
+msgstr "Der Roundup-Server wurde unter %(HOST)s:%(PORT)s gestartet"
+
+#: ../templates/classic/html/_generic.collision.html:4
+#: ../templates/minimal/html/_generic.collision.html:4
+msgid "${class} Edit Collision - ${tracker}"
+msgstr "Kollision bei der Bearbeitung - ${tracker}"
+
+#: ../templates/classic/html/_generic.collision.html:7
+#: ../templates/minimal/html/_generic.collision.html:7
+msgid "${class} Edit Collision"
+msgstr "Kollision bei der Bearbeitung"
+
+#: ../templates/classic/html/_generic.collision.html:14
+#: ../templates/minimal/html/_generic.collision.html:14
+msgid ""
+"\n"
+"  There has been a collision. Another user updated this node\n"
+"  while you were editing. Please <a href='${context}'>reload</a>\n"
+"  the node and review your edits.\n"
+msgstr ""
+"\n"
+"  Eine Kollision wurde festgestellt. Während Ihrer Bearbeitung\n"
+"  hat ein anderer Benutzer diesen Eintrag aktualisiert. Bitte   <a "
+"href='${context}'>laden Sie diese Seite neu</a> \n"
+"  und fügen Sie Ihre Änderungen erneut ein.\n"
+
+#: ../templates/classic/html/_generic.help.html:9
+#: ../templates/minimal/html/_generic.help.html:9
+msgid "${property} help - ${tracker}"
+msgstr "Hilfe zu \"${property}\" - ${tracker}"
+
+#: ../templates/classic/html/_generic.help.html:30
+#: ../templates/minimal/html/_generic.help.html:30
+msgid " Cancel "
+msgstr " Abbrechen "
+
+#: ../templates/classic/html/_generic.help.html:33
+#: ../templates/minimal/html/_generic.help.html:33
+msgid " Apply "
+msgstr " Bestätigen "
+
+#: ../templates/classic/html/_generic.help.html:40
+#: ../templates/classic/html/issue.index.html:67
+#: ../templates/minimal/html/_generic.help.html:40
+msgid "&lt;&lt; previous"
+msgstr "&lt;&lt; zurück"
+
+#: ../templates/classic/html/_generic.help.html:50
+#: ../templates/classic/html/issue.index.html:75
+#: ../templates/minimal/html/_generic.help.html:50
+msgid "${start}..${end} out of ${total}"
+msgstr "${start}..${end} von ${total}"
+
+#: ../templates/classic/html/_generic.help.html:54
+#: ../templates/classic/html/issue.index.html:78
+#: ../templates/minimal/html/_generic.help.html:54
+msgid "next &gt;&gt;"
+msgstr "Weiter &gt;&gt;"
+
+#: ../templates/classic/html/_generic.index.html:6
+#: ../templates/classic/html/_generic.item.html:4
+#: ../templates/minimal/html/_generic.index.html:6
+#: ../templates/minimal/html/_generic.item.html:4
+msgid "${class} editing - ${tracker}"
+msgstr "Klasse bearbeiten - ${tracker}"
+
+#: ../templates/classic/html/_generic.index.html:9
+#: ../templates/classic/html/_generic.item.html:7
+#: ../templates/minimal/html/_generic.index.html:9
+#: ../templates/minimal/html/_generic.item.html:7
+msgid "${class} editing"
+msgstr "\"${class}\" bearbeiten"
+
+#: ../templates/classic/html/_generic.index.html:14
+#: ../templates/classic/html/_generic.item.html:12
+#: ../templates/classic/html/file.item.html:9
+#: ../templates/classic/html/issue.index.html:10
+#: ../templates/classic/html/user.index.html:9
+#: ../templates/minimal/html/_generic.index.html:14
+#: ../templates/minimal/html/_generic.item.html:12
+#: ../templates/minimal/html/user.index.html:9
+#: ../templates/minimal/html/user.item.html:18
+#: ../templates/minimal/html/user.register.html:14
+msgid "You are not allowed to view this page."
+msgstr "Sie haben nicht die Berechtigung, diese Seite anzuzeigen."
+
+#: ../templates/classic/html/_generic.index.html:22
+#: ../templates/minimal/html/_generic.index.html:22
+msgid ""
+"<p class=\"form-help\"> You may edit the contents of the ${classname} class "
+"using this form. Commas, newlines and double quotes (\") must be handled "
+"delicately. You may include commas and newlines by enclosing the values in "
+"double-quotes (\"). Double quotes themselves must be quoted by doubling "
+"(\"\"). </p> <p class=\"form-help\"> Multilink properties have their "
+"multiple values colon (\":\") separated (... ,\"one:two:three\", ...) </p> "
+"<p class=\"form-help\"> Remove entries by deleting their line. Add new "
+"entries by appending them to the table - put an X in the id column. </p>"
+msgstr ""
+"<p class=\"form-help\"> Sie können die Einträge der Klasse \"${classname}\" "
+"mit diesem Formular bearbeiten. Kommas, Zeilenschaltungen und "
+"Anführungszeichen (\") mit Vorsicht verwenden. Kommas und Zeilenschaltungen "
+"dürfen nur Anführungszeichen (\") verwendet werden. Um Anführungszeichen in "
+"Werten zu verwenden, müssen Sie verdoppelt werden (\"\"). </p> <p class="
+"\"form-help\"> Mehrfachlinks werden durch Doppeltpunkt (\":\") getrennt "
+"(... ,\"eins:zwei:drei\", ...) </p> <p class=\"form-help\"> Einträge können "
+"gelöscht werden, indem Sie Zeilen entfernen. Fügen Sie Zeilen ein für neue "
+"Einträge und geben Sie bei der ID-Spalte ein X an. </p>"
+
+#: ../templates/classic/html/_generic.index.html:44
+#: ../templates/minimal/html/_generic.index.html:44
+msgid "Edit Items"
+msgstr "Einträge bearbeiten"
+
+#: ../templates/classic/html/file.index.html:4
+msgid "List of files - ${tracker}"
+msgstr "Dateiliste - ${tracker}"
+
+#: ../templates/classic/html/file.index.html:5
+msgid "List of files"
+msgstr "Dateiliste"
+
+#: ../templates/classic/html/file.index.html:10
+msgid "Download"
+msgstr "Herunterladen"
+
+#: ../templates/classic/html/file.index.html:11
+#: ../templates/classic/html/file.item.html:23
+#: ../templates/classic/html/file.item.html:51
+msgid "Content Type"
+msgstr "Inhaltstyp"
+
+#: ../templates/classic/html/file.index.html:12
+msgid "Uploaded By"
+msgstr "Hochgeladen von"
+
+#: ../templates/classic/html/file.index.html:13
+#: ../templates/classic/html/msg.item.html:38
+msgid "Date"
+msgstr "Datum"
+
+#: ../templates/classic/html/file.item.html:2
+msgid "File display - ${tracker}"
+msgstr "Datei anzeigen - ${tracker}"
+
+#: ../templates/classic/html/file.item.html:4
+msgid "File display"
+msgstr "Datei anzeigen"
+
+#: ../templates/classic/html/file.item.html:19
+#: ../templates/classic/html/file.item.html:47
+#: ../templates/classic/html/user.item.html:34
+#: ../templates/classic/html/user.register.html:17
+msgid "Name"
+msgstr "Name"
+
+#: ../templates/classic/html/file.item.html:41
+msgid "download"
+msgstr "herunterladen"
+
+#: ../templates/classic/html/home.classlist.html:2
+#: ../templates/minimal/html/home.classlist.html:2
+msgid "List of classes - ${tracker}"
+msgstr "Klassenliste - ${tracker}"
+
+#: ../templates/classic/html/home.classlist.html:4
+#: ../templates/minimal/html/home.classlist.html:4
+msgid "List of classes"
+msgstr "Klassenliste"
+
+#: ../templates/classic/html/issue.index.html:4
+msgid "List of issues - ${tracker}"
+msgstr "Aufgabenliste - ${tracker}"
+
+#: ../templates/classic/html/issue.index.html:6
+msgid "List of issues"
+msgstr "Aufgabenliste"
+
+#: ../templates/classic/html/issue.index.html:17
+#: ../templates/classic/html/issue.item.html:38
+msgid "Priority"
+msgstr "Priorität"
+
+#: ../templates/classic/html/issue.index.html:18
+msgid "ID"
+msgstr "ID"
+
+#: ../templates/classic/html/issue.index.html:19
+msgid "Creation"
+msgstr "Erstellungsdatum"
+
+#: ../templates/classic/html/issue.index.html:20
+msgid "Activity"
+msgstr "Aktivität"
+
+#: ../templates/classic/html/issue.index.html:21
+msgid "Actor"
+msgstr "Akteur"
+
+#: ../templates/classic/html/issue.index.html:22
+msgid "Topic"
+msgstr "Thema"
+
+#: ../templates/classic/html/issue.index.html:23
+#: ../templates/classic/html/issue.item.html:33
+msgid "Title"
+msgstr "Titel"
+
+#: ../templates/classic/html/issue.index.html:24
+#: ../templates/classic/html/issue.item.html:40
+msgid "Status"
+msgstr "Status"
+
+#: ../templates/classic/html/issue.index.html:25
+msgid "Creator"
+msgstr "Ersteller"
+
+#: ../templates/classic/html/issue.index.html:26
+msgid "Assigned&nbsp;To"
+msgstr "Zugewiesen"
+
+#: ../templates/classic/html/issue.index.html:90
+msgid "Download as CSV"
+msgstr "Als CSV-Datei herunterladen"
+
+#: ../templates/classic/html/issue.index.html:98
+msgid "Sort on:"
+msgstr "Sortieren:"
+
+#: ../templates/classic/html/issue.index.html:101
+#: ../templates/classic/html/issue.index.html:118
+msgid "- nothing -"
+msgstr "- nichts -"
+
+#: ../templates/classic/html/issue.index.html:109
+#: ../templates/classic/html/issue.index.html:126
+msgid "Descending:"
+msgstr "Absteigend:"
+
+#: ../templates/classic/html/issue.index.html:115
+msgid "Group on:"
+msgstr "Gruppieren:"
+
+#: ../templates/classic/html/issue.index.html:132
+msgid "Redisplay"
+msgstr "Aktualisieren"
+
+#: ../templates/classic/html/issue.item.html:7
+msgid "Issue ${id}: ${title} - ${tracker}"
+msgstr "Aufgabe ${id}: ${title} - ${tracker}"
+
+#: ../templates/classic/html/issue.item.html:10
+msgid "New Issue - ${tracker}"
+msgstr "Neue Aufgabe - ${tracker}"
+
+#: ../templates/classic/html/issue.item.html:14
+msgid "New Issue"
+msgstr "Neue Aufgabe"
+
+#: ../templates/classic/html/issue.item.html:16
+msgid "New Issue Editing"
+msgstr "Neue Aufgabe bearbeiten"
+
+#: ../templates/classic/html/issue.item.html:19
+msgid "Issue${id}"
+msgstr "Aufgabe${id}"
+
+#: ../templates/classic/html/issue.item.html:22
+msgid "Issue${id} Editing"
+msgstr "Aufgabe${id} bearbeiten"
+
+#: ../templates/classic/html/issue.item.html:45
+msgid "Superseder"
+msgstr "Übergeordnete Aufgabe"
+
+#: ../templates/classic/html/issue.item.html:50
+msgid "View: ${link}"
+msgstr "Anzeigen: ${link}"
+
+#: ../templates/classic/html/issue.item.html:54
+msgid "Nosy List"
+msgstr "Interessenten"
+
+#: ../templates/classic/html/issue.item.html:63
+msgid "Assigned To"
+msgstr "Zugewiesen"
+
+#: ../templates/classic/html/issue.item.html:65
+msgid "Topics"
+msgstr "Themen"
+
+#: ../templates/classic/html/issue.item.html:73
+msgid "Change Note"
+msgstr "Änderungsnotiz"
+
+#: ../templates/classic/html/issue.item.html:81
+msgid "File"
+msgstr "Datei"
+
+#: ../templates/classic/html/issue.item.html:100
+msgid ""
+"<table class=\"form\"> <tr> <td>Note:&nbsp;</td> <th class=\"required"
+"\">highlighted</th> <td>&nbsp;fields are required.</td> </tr> </table>"
+msgstr ""
+"<table class=\"form\"> <tr> <td>Bemerkungen:&nbsp;</td> <th class=\"required"
+"\">Fett markierte</th> <td>&nbsp;Felder sind immer auszufüllen. </td> </tr> "
+"</table>"
+
+#: ../templates/classic/html/issue.item.html:114
+msgid ""
+"Created on <b>${creation}</b> by <b>${creator}</b>, last changed <b>"
+"${activity}</b> by <b>${actor}</b>."
+msgstr ""
+"Erstellt am <b>${creation}</b> durch <b>${creator}</b>, geändert am <b>"
+"${activity}</b> durch <b>${actor}</b>."
+
+#: ../templates/classic/html/issue.item.html:118
+#: ../templates/classic/html/msg.item.html:51
+msgid "Files"
+msgstr "Dateien"
+
+#: ../templates/classic/html/issue.item.html:120
+#: ../templates/classic/html/msg.item.html:53
+msgid "File name"
+msgstr "Dateiname"
+
+#: ../templates/classic/html/issue.item.html:121
+#: ../templates/classic/html/msg.item.html:54
+msgid "Uploaded"
+msgstr "Hochgeladen"
+
+#: ../templates/classic/html/issue.item.html:122
+msgid "Type"
+msgstr "Typ"
+
+#: ../templates/classic/html/issue.item.html:123
+#: ../templates/classic/html/query.edit.html:30
+msgid "Edit"
+msgstr "Bearbeiten"
+
+#: ../templates/classic/html/issue.item.html:124
+msgid "Remove"
+msgstr "Verbergen"
+
+#: ../templates/classic/html/issue.item.html:144
+#: ../templates/classic/html/issue.item.html:165
+#: ../templates/classic/html/query.edit.html:50
+msgid "remove"
+msgstr "Nein"
+
+#: ../templates/classic/html/issue.item.html:151
+#: ../templates/classic/html/msg.index.html:9
+msgid "Messages"
+msgstr "Meldungen"
+
+#: ../templates/classic/html/issue.item.html:155
+msgid "msg${id} (view)"
+msgstr "msg${id} (betrachten)"
+
+#: ../templates/classic/html/issue.item.html:156
+msgid "Author: ${author}"
+msgstr "Autor: ${author}"
+
+#: ../templates/classic/html/issue.item.html:158
+msgid "Date: ${date}"
+msgstr "Datum: ${date}"
+
+#: ../templates/classic/html/issue.search.html:2
+msgid "Issue searching - ${tracker}"
+msgstr "Aufgaben suchen - ${tracker}"
+
+#: ../templates/classic/html/issue.search.html:4
+msgid "Issue searching"
+msgstr "Aufgaben suchen"
+
+#: ../templates/classic/html/issue.search.html:25
+msgid "Filter on"
+msgstr "Filtern"
+
+#: ../templates/classic/html/issue.search.html:26
+msgid "Display"
+msgstr "Anzeigen"
+
+#: ../templates/classic/html/issue.search.html:27
+msgid "Sort on"
+msgstr "Sortieren"
+
+#: ../templates/classic/html/issue.search.html:28
+msgid "Group on"
+msgstr "Gruppieren"
+
+#: ../templates/classic/html/issue.search.html:32
+msgid "All text*:"
+msgstr "Volltext*:"
+
+#: ../templates/classic/html/issue.search.html:40
+msgid "Title:"
+msgstr "Titel:"
+
+#: ../templates/classic/html/issue.search.html:50
+msgid "Topic:"
+msgstr "Thema:"
+
+#: ../templates/classic/html/issue.search.html:58
+msgid "ID:"
+msgstr "ID:"
+
+#: ../templates/classic/html/issue.search.html:66
+msgid "Creation Date:"
+msgstr "Erstellungsdatum:"
+
+#: ../templates/classic/html/issue.search.html:77
+msgid "Creator:"
+msgstr "Ersteller:"
+
+#: ../templates/classic/html/issue.search.html:79
+msgid "created by me"
+msgstr "Durch mich erstellt"
+
+#: ../templates/classic/html/issue.search.html:88
+msgid "Activity:"
+msgstr "Aktivität:"
+
+#: ../templates/classic/html/issue.search.html:99
+msgid "Actor:"
+msgstr "Akteur:"
+
+#: ../templates/classic/html/issue.search.html:101
+msgid "done by me"
+msgstr "durch mich"
+
+#: ../templates/classic/html/issue.search.html:112
+msgid "Priority:"
+msgstr "Priorität:"
+
+#: ../templates/classic/html/issue.search.html:114
+#: ../templates/classic/html/issue.search.html:130
+msgid "not selected"
+msgstr "Nicht gewählt"
+
+#: ../templates/classic/html/issue.search.html:125
+msgid "Status:"
+msgstr "Status:"
+
+#: ../templates/classic/html/issue.search.html:128
+msgid "not resolved"
+msgstr "Ungelöst"
+
+#: ../templates/classic/html/issue.search.html:143
+msgid "Assigned to:"
+msgstr "Zugewiesen:"
+
+#: ../templates/classic/html/issue.search.html:146
+msgid "assigned to me"
+msgstr "Mir zugewiesen"
+
+#: ../templates/classic/html/issue.search.html:148
+msgid "unassigned"
+msgstr "Nicht zugewiesen"
+
+#: ../templates/classic/html/issue.search.html:158
+msgid "Pagesize:"
+msgstr "Pro Seite:"
+
+#: ../templates/classic/html/issue.search.html:164
+msgid "Start With:"
+msgstr "Starten bei:"
+
+#: ../templates/classic/html/issue.search.html:170
+msgid "Sort Descending:"
+msgstr "Absteigend sortieren:"
+
+#: ../templates/classic/html/issue.search.html:177
+msgid "Group Descending:"
+msgstr "Absteigend gruppieren:"
+
+#: ../templates/classic/html/issue.search.html:184
+msgid "Query name**:"
+msgstr "Speichern unter**:"
+
+#: ../templates/classic/html/issue.search.html:194
+#: ../templates/classic/html/page.html:47
+msgid "Search"
+msgstr "Suchen"
+
+#: ../templates/classic/html/issue.search.html:198
+msgid ""
+"*: The \"all text\" field will look in message bodies and issue titles<br> "
+"**: If you supply a name, the query will be saved off and available as a "
+"link in the sidebar"
+msgstr ""
+"*: Das Feld \"Volltext\" durchsucht Titel von Aufgaben und Meldungstexte<br> "
+"**: Geben Sie einen Namen für diese Abfrage ein, um sie in der Seitenleiste "
+"zu speichern. "
+
+#: ../templates/classic/html/keyword.item.html:3
+msgid "Keyword editing - ${tracker}"
+msgstr "Stichwort bearbeiten - ${tracker}"
+
+#: ../templates/classic/html/keyword.item.html:5
+msgid "Keyword editing"
+msgstr "Stichwort bearbeiten"
+
+#: ../templates/classic/html/keyword.item.html:11
+msgid "Existing Keywords"
+msgstr "Vorhandene Stichworte"
+
+#: ../templates/classic/html/keyword.item.html:20
+msgid ""
+"To edit an existing keyword (for spelling or typing errors), click on its "
+"entry above."
+msgstr "Um ein bestehendes Stichwort zu bearbeiten, klicken Sie darauf."
+
+#: ../templates/classic/html/keyword.item.html:27
+msgid "To create a new keyword, enter it below and click \"Submit New Entry\"."
+msgstr ""
+"Um ein neues Stichwort hinzufügen, tragen Sie es hier ein und klicken Sie "
+"auf \"Eintrag speichern\"."
+
+#: ../templates/classic/html/keyword.item.html:37
+msgid "Keyword"
+msgstr "Stichwort"
+
+#: ../templates/classic/html/msg.index.html:3
+msgid "List of messages - ${tracker}"
+msgstr "Meldungsliste - ${tracker}"
+
+#: ../templates/classic/html/msg.index.html:5
+msgid "Message listing"
+msgstr "Meldungsliste"
+
+#: ../templates/classic/html/msg.item.html:6
+msgid "Message ${id} - ${tracker}"
+msgstr "Meldung ${id} - ${tracker}"
+
+#: ../templates/classic/html/msg.item.html:9
+msgid "New Message - ${tracker}"
+msgstr "Neue Meldung - ${tracker}"
+
+#: ../templates/classic/html/msg.item.html:13
+msgid "New Message"
+msgstr "Neue Meldung"
+
+#: ../templates/classic/html/msg.item.html:15
+msgid "New Message Editing"
+msgstr "Neue Meldung bearbeiten"
+
+#: ../templates/classic/html/msg.item.html:18
+msgid "Message${id}"
+msgstr "Message${id}"
+
+#: ../templates/classic/html/msg.item.html:21
+msgid "Message${id} Editing"
+msgstr "Meldung${id} bearbeiten"
+
+#: ../templates/classic/html/msg.item.html:28
+msgid "Author"
+msgstr "Autor"
+
+#: ../templates/classic/html/msg.item.html:33
+msgid "Recipients"
+msgstr "Empfänger"
+
+#: ../templates/classic/html/msg.item.html:44
+msgid "Content"
+msgstr "Inhalt"
+
+#: ../templates/classic/html/page.html:28
+msgid "<b>Your Queries</b> (<a href=\"query?@template=edit\">edit</a>)"
+msgstr "<b>Abfragen</b> (<a href=\"query?@template=edit\">bearbeiten</a>)"
+
+#: ../templates/classic/html/page.html:39
+msgid "Issues"
+msgstr "Aufgaben"
+
+#: ../templates/classic/html/page.html:41
+#: ../templates/classic/html/page.html:60
+msgid "Create New"
+msgstr "Neuer Eintrag"
+
+#: ../templates/classic/html/page.html:43
+msgid "Show Unassigned"
+msgstr "Nicht zugewiesen"
+
+#: ../templates/classic/html/page.html:45
+msgid "Show All"
+msgstr "Alle anzeigen"
+
+#: ../templates/classic/html/page.html:48
+msgid "Show issue:"
+msgstr "Aufgabe zeigen:"
+
+#: ../templates/classic/html/page.html:58
+msgid "Keywords"
+msgstr "Stichworte"
+
+#: ../templates/classic/html/page.html:64
+msgid "Edit Existing"
+msgstr "Bearbeiten"
+
+#: ../templates/classic/html/page.html:70
+#: ../templates/minimal/html/page.html:48
+msgid "Administration"
+msgstr "Administration"
+
+#: ../templates/classic/html/page.html:72
+#: ../templates/minimal/html/page.html:49
+msgid "Class List"
+msgstr "Klassenliste"
+
+#: ../templates/classic/html/page.html:76
+#: ../templates/minimal/html/page.html:51
+msgid "User List"
+msgstr "Benutzerliste"
+
+#: ../templates/classic/html/page.html:78
+#: ../templates/minimal/html/page.html:54
+msgid "Add User"
+msgstr "Benutzer hinzufügen"
+
+#: ../templates/classic/html/page.html:85
+#: ../templates/classic/html/page.html:89
+#: ../templates/minimal/html/page.html:30
+msgid "Login"
+msgstr "Anmelden"
+
+#: ../templates/classic/html/page.html:91
+#: ../templates/classic/html/user.register.html:63
+#: ../templates/minimal/html/page.html:33
+#: ../templates/minimal/html/user.register.html:58
+msgid "Register"
+msgstr "Registrieren"
+
+#: ../templates/classic/html/page.html:94
+msgid "Lost&nbsp;your&nbsp;login?"
+msgstr "Passwort&nbsp;vergessen?"
+
+#: ../templates/classic/html/page.html:99
+msgid "Hello, ${user}"
+msgstr "Guten Tag, ${user}"
+
+#: ../templates/classic/html/page.html:101
+msgid "Your Issues"
+msgstr "Ihre Aufgaben"
+
+#: ../templates/classic/html/page.html:102
+#: ../templates/minimal/html/page.html:40
+msgid "Your Details"
+msgstr "Ihr Konto"
+
+#: ../templates/classic/html/page.html:104
+#: ../templates/minimal/html/page.html:42
+msgid "Logout"
+msgstr "Abmelden"
+
+#: ../templates/classic/html/page.html:108
+msgid "Help"
+msgstr "Hilfe"
+
+#: ../templates/classic/html/page.html:109
+msgid "Roundup docs"
+msgstr "Roundup Handbuch"
+
+#: ../templates/classic/html/page.html:160
+msgid "don't care"
+msgstr "egal"
+
+#: ../templates/classic/html/page.html:162
+msgid "------------"
+msgstr "------------"
+
+#: ../templates/classic/html/page.html:188
+msgid "no value"
+msgstr "kein Wert"
+
+#: ../templates/classic/html/query.edit.html:4
+msgid "\"Your Queries\" Editing - ${tracker}"
+msgstr "\"Abfragen\" berabeiten - ${tracker}"
+
+#: ../templates/classic/html/query.edit.html:6
+msgid "\"Your Queries\" Editing"
+msgstr "\"Abfragen\" berabeiten"
+
+#: ../templates/classic/html/query.edit.html:11
+msgid "You are not allowed to edit queries."
+msgstr "Sie haben keine Berechtigung um Abfragen zu bearbeiten."
+
+#: ../templates/classic/html/query.edit.html:28
+msgid "Query"
+msgstr "Abfrage"
+
+#: ../templates/classic/html/query.edit.html:29
+msgid "Include in \"Your Queries\""
+msgstr "Unter \"Abfragen\" aufführen"
+
+#: ../templates/classic/html/query.edit.html:31
+msgid "Private to you?"
+msgstr "Nur für Sie?"
+
+#: ../templates/classic/html/query.edit.html:44
+msgid "leave out"
+msgstr "Nein"
+
+#: ../templates/classic/html/query.edit.html:45
+msgid "include"
+msgstr "Ja"
+
+#: ../templates/classic/html/query.edit.html:49
+msgid "leave in"
+msgstr "Ja"
+
+#: ../templates/classic/html/query.edit.html:54
+msgid "[query is retired]"
+msgstr "[Abfrage ist verborgen]"
+
+#: ../templates/classic/html/query.edit.html:67
+msgid "edit"
+msgstr "bearbeiten"
+
+#: ../templates/classic/html/query.edit.html:71
+msgid "yes"
+msgstr "ja"
+
+#: ../templates/classic/html/query.edit.html:73
+msgid "no"
+msgstr "nein"
+
+#: ../templates/classic/html/query.edit.html:79
+msgid "Delete"
+msgstr "Löschen"
+
+#: ../templates/classic/html/query.edit.html:90
+msgid "[not yours to edit]"
+msgstr "[nicht Ihr Eintrag]"
+
+#: ../templates/classic/html/query.edit.html:96
+msgid "Save Selection"
+msgstr "Auswahl speichern"
+
+#: ../templates/classic/html/user.forgotten.html:3
+msgid "Password reset request - ${tracker}"
+msgstr "Passwort zurücksetzen - ${tracker}"
+
+#: ../templates/classic/html/user.forgotten.html:5
+msgid "Password reset request"
+msgstr "Passwort zurücksetzen"
+
+#: ../templates/classic/html/user.forgotten.html:9
+msgid ""
+"You have two options if you have forgotten your password. If you know the "
+"email address you registered with, enter it below."
+msgstr ""
+"Um Ihr Passwort zurückzusetzen, geben Sie entweder die Email-Adresse an, mit "
+"welcher Sie sich registriert haben..."
+
+#: ../templates/classic/html/user.forgotten.html:16
+msgid "Email Address:"
+msgstr "Email-Adresse"
+
+#: ../templates/classic/html/user.forgotten.html:24
+#: ../templates/classic/html/user.forgotten.html:34
+msgid "Request password reset"
+msgstr "Passwort zurücksetzen"
+
+#: ../templates/classic/html/user.forgotten.html:30
+msgid "Or, if you know your username, then enter it below."
+msgstr "... oder Ihren Benutzernamen."
+
+#: ../templates/classic/html/user.forgotten.html:33
+msgid "Username:"
+msgstr "Benutzername:"
+
+#: ../templates/classic/html/user.forgotten.html:39
+msgid ""
+"A confirmation email will be sent to you - please follow the instructions "
+"within it to complete the reset process."
+msgstr ""
+"Danach wird ein Bestätigungs-Email verschickt. Bitte folgen Sie den "
+"Anweisungen im Email, um ihr Passwort zurückzusetzen."
+
+#: ../templates/classic/html/user.index.html:3
+#: ../templates/minimal/html/user.index.html:3
+msgid "User listing - ${tracker}"
+msgstr "Benutzerliste - ${tracker}"
+
+#: ../templates/classic/html/user.index.html:5
+#: ../templates/minimal/html/user.index.html:5
+msgid "User listing"
+msgstr "Benutzerliste"
+
+#: ../templates/classic/html/user.index.html:14
+#: ../templates/minimal/html/user.index.html:14
+msgid "Username"
+msgstr "Benutzername"
+
+#: ../templates/classic/html/user.index.html:15
+msgid "Real name"
+msgstr "Name"
+
+#: ../templates/classic/html/user.index.html:16
+#: ../templates/classic/html/user.item.html:65
+#: ../templates/classic/html/user.register.html:45
+msgid "Organisation"
+msgstr "Organisation"
+
+#: ../templates/classic/html/user.index.html:17
+#: ../templates/minimal/html/user.index.html:15
+msgid "Email address"
+msgstr "Email-Adresse"
+
+#: ../templates/classic/html/user.index.html:18
+msgid "Phone number"
+msgstr "Telefonnummer"
+
+#: ../templates/classic/html/user.index.html:19
+msgid "Retire"
+msgstr "Verbergen"
+
+#: ../templates/classic/html/user.index.html:32
+msgid "retire"
+msgstr "verbergen"
+
+#: ../templates/classic/html/user.item.html:7
+msgid "User ${id}: ${title} - ${tracker}"
+msgstr "Benutzer ${id}: ${title} - ${tracker}"
+
+#: ../templates/classic/html/user.item.html:10
+msgid "New User - ${tracker}"
+msgstr "Neuer Benutzer - ${tracker}"
+
+#: ../templates/classic/html/user.item.html:14
+#: ../templates/minimal/html/user.item.html:6
+msgid "New User"
+msgstr "Neuer Benutzer"
+
+#: ../templates/classic/html/user.item.html:16
+#: ../templates/minimal/html/user.item.html:8
+msgid "New User Editing"
+msgstr "Neuen Benutzer bearbeiten"
+
+#: ../templates/classic/html/user.item.html:19
+#: ../templates/minimal/html/user.item.html:11
+msgid "User${id}"
+msgstr "Benutzer${id}"
+
+#: ../templates/classic/html/user.item.html:22
+#: ../templates/minimal/html/user.item.html:14
+msgid "User${id} Editing"
+msgstr "Benutzer${id} bearbeiten"
+
+#: ../templates/classic/html/user.item.html:38
+#: ../templates/classic/html/user.register.html:21
+#: ../templates/minimal/html/user.item.html:27
+#: ../templates/minimal/html/user.item.html:67
+#: ../templates/minimal/html/user.register.html:26
+msgid "Login Name"
+msgstr "Benutzername"
+
+#: ../templates/classic/html/user.item.html:42
+#: ../templates/classic/html/user.register.html:25
+#: ../templates/minimal/html/user.item.html:31
+#: ../templates/minimal/html/user.register.html:30
+msgid "Login Password"
+msgstr "Passwort"
+
+#: ../templates/classic/html/user.item.html:46
+#: ../templates/classic/html/user.register.html:29
+#: ../templates/minimal/html/user.item.html:35
+#: ../templates/minimal/html/user.register.html:34
+msgid "Confirm Password"
+msgstr "Passwort bestätigen"
+
+#: ../templates/classic/html/user.item.html:50
+#: ../templates/classic/html/user.register.html:33
+#: ../templates/minimal/html/user.item.html:39
+#: ../templates/minimal/html/user.register.html:38
+msgid "Roles"
+msgstr "Rollen"
+
+#: ../templates/classic/html/user.item.html:56
+msgid "(to give the user more than one role, enter a comma,separated,list)"
+msgstr "Verwenden,Sie,Kommas, um einem Benutzer mehrere Rollen zuzuteilen"
+
+#: ../templates/classic/html/user.item.html:61
+#: ../templates/classic/html/user.register.html:41
+msgid "Phone"
+msgstr "Telefon"
+
+#: ../templates/classic/html/user.item.html:69
+msgid "Timezone"
+msgstr "Zeitzone"
+
+#: ../templates/classic/html/user.item.html:73
+msgid "(this is a numeric hour offset, the default is ${zone})"
+msgstr "Zeitverschiebung in Stunden - Voreinstellung: ${zone}"
+
+#: ../templates/classic/html/user.item.html:78
+#: ../templates/classic/html/user.register.html:49
+#: ../templates/minimal/html/user.item.html:47
+#: ../templates/minimal/html/user.item.html:71
+#: ../templates/minimal/html/user.register.html:46
+msgid "E-mail address"
+msgstr "Email-Adresse"
+
+#: ../templates/classic/html/user.item.html:82
+#: ../templates/classic/html/user.register.html:53
+#: ../templates/minimal/html/user.item.html:51
+#: ../templates/minimal/html/user.register.html:50
+msgid "Alternate E-mail addresses<br>One address per line"
+msgstr "Alternative Email-Adressen<br>Eine pro Zeile"
+
+#: ../templates/classic/html/user.register.html:4
+#: ../templates/classic/html/user.register.html:7
+#: ../templates/minimal/html/user.register.html:4
+#: ../templates/minimal/html/user.register.html:7
+msgid "Registering with ${tracker}"
+msgstr "Registrieren für ${tracker}"
+
+#: ../templates/classic/html/user.rego_progress.html:4
+#: ../templates/minimal/html/user.rego_progress.html:4
+msgid "Registration in progress - ${tracker}"
+msgstr "Die Registration is am laufen - ${tracker}"
+
+#: ../templates/classic/html/user.rego_progress.html:6
+#: ../templates/minimal/html/user.rego_progress.html:6
+msgid "Registration in progress..."
+msgstr "Die Registration ist am Gange..."
+
+#: ../templates/classic/html/user.rego_progress.html:10
+#: ../templates/minimal/html/user.rego_progress.html:10
+msgid ""
+"You will shortly receive an email to confirm your registration. To complete "
+"the registration process, visit the link indicated in the email."
+msgstr ""
+"Sie werden in Kürze ein Bestätigungs-Email erhalten. Um die Registrierung "
+"abzuschliessen, klicken Sie auf den Link im Email."
+
+#: ../templates/minimal/html/home.html:2
+msgid "Tracker home - ${tracker}"
+msgstr "Tracker Start - $ {tracker}"
+
+#: ../templates/minimal/html/home.html:4
+msgid "Tracker home"
+msgstr "Tracker Start"
+
+#: ../templates/minimal/html/home.html:16
+msgid "Please select from one of the menu options on the left."
+msgstr "Bitte wählen Sie links eine Menu-Option."
+
+#: ../templates/minimal/html/home.html:19
+msgid "Please log in or register."
+msgstr "Bitte anmelden oder registrieren"
+
+#: ../templates/minimal/html/page.html:38
+msgid "Hello,<br>${user}"
+msgstr "Guten Tag,<br>${user}"
+
+#: ../templates/minimal/html/user.item.html:3
+msgid "User editing - ${tracker}"
+msgstr "Benutzer bearbeiten - ${tracker}"

Added: tracker/vendor/roundup/current/locale/en.po
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/locale/en.po	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,20 @@
+# English message file for Roundup Issue Tracker
+#
+# $Id: en.po,v 1.2 2004/11/20 11:54:32 a1s Exp $
+#
+# roundup.pot revision 1.9
+#
+# Currently Roundup has no strings that need english translation.
+# This file is a dummy needed to provide the user with english UI
+# if 'en' is the first item in locale preference list and the list
+# also contains existing Roundup locale name.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Roundup 0.7.0\n"
+"Report-Msgid-Bugs-To: roundup-devel at lists.sourceforge.net\n"
+"POT-Creation-Date: 2004-07-13 13:24+0300\n"
+"PO-Revision-Date: 2004-11-20 13:47+0200\n"
+"Language-Team: English\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=us-ascii\n"

Added: tracker/vendor/roundup/current/locale/es_AR.po
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/locale/es_AR.po	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,2978 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR See Roundup README.txt
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR <EMAIL at ADDRESS>, YEAR.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Roundup 0.8.4\n"
+"Report-Msgid-Bugs-To: roundup-devel at lists.sourceforge.net\n"
+"POT-Creation-Date: 2005-07-05 09:37+0300\n"
+"PO-Revision-Date: 2005-08-01 10:57:00-0300\n"
+"Last-Translator: Ramiro Morales <rm0 at gmx.net>\n"
+"Language-Team: Spanish Translators <roundup-devel at lists.sourceforge.net>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=iso-8859-1\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+# ../roundup/admin.py:85 :955 :1004 :1026
+#: ../roundup/admin.py:85
+#: ../roundup/admin.py:962
+#: ../roundup/admin.py:1011
+#: ../roundup/admin.py:1033
+#, python-format
+msgid "no such class \"%(classname)s\""
+msgstr "la clase \"%(classname)s\" no existe"
+
+# ../roundup/admin.py:95 :99
+#: ../roundup/admin.py:95
+#: ../roundup/admin.py:99
+#, python-format
+msgid "argument \"%(arg)s\" not propname=value"
+msgstr "el argumento \"%(arg)s\" no es de la forma nombrepropiedad=valor"
+
+#: ../roundup/admin.py:112
+#, python-format
+msgid ""
+"Problem: %(message)s\n"
+"\n"
+msgstr ""
+"Problema: %(message)s\n"
+"\n"
+
+#: ../roundup/admin.py:113
+#, python-format
+msgid ""
+"%(message)sUsage: roundup-admin [options] [<command> <arguments>]\n"
+"\n"
+"Options:\n"
+" -i instance home  -- specify the issue tracker \"home directory\" to administer\n"
+" -u                -- the user[:password] to use for commands\n"
+" -d                -- print full designators not just class id numbers\n"
+" -c                -- when outputting lists of data, comma-separate them.\n"
+"                      Same as '-S \",\"'.\n"
+" -S <string>       -- when outputting lists of data, string-separate them\n"
+" -s                -- when outputting lists of data, space-separate them.\n"
+"                      Same as '-S \" \"'.\n"
+"\n"
+" Only one of -s, -c or -S can be specified.\n"
+"\n"
+"Help:\n"
+" roundup-admin -h\n"
+" roundup-admin help                       -- this help\n"
+" roundup-admin help <command>             -- command-specific help\n"
+" roundup-admin help all                   -- all available help\n"
+msgstr ""
+"%(message)sUso: roundup-admin [opciones] [<comando> <argumentos>]\n"
+"\n"
+"Opciones:\n"
+" -i base de instancia  -- especifica el \"directorio base\" del issue tracker\n"
+"                          sobre el cual se va a actuar\n"
+" -u                    -- usuario[:contraseña] a usarse para los comandos\n"
+" -d                    -- imprime designadores completos, no solamente números\n"
+"                          de identificación de clases\n"
+" -c                    -- separa los elementos con comas cuando se generan listas de\n"
+"                          salida.\n"
+"                          Equivalente a '-S \",\"'.\n"
+" -S <string>           -- separa los elementos con cadenas cuando se generan listas\n"
+"                          de salida\n"
+" -s                    -- separa los elementos con espacios cuando se generan listas\n"
+"                          de salida\n"
+"                          Equivalente a '-S \" \"'.\n"
+"\n"
+" Sólo puede especificarse una de las opciones -s, -c o -S.\n"
+"\n"
+"Ayuda:\n"
+" roundup-admin -h\n"
+" roundup-admin help                       -- esta ayuda\n"
+" roundup-admin help <comando>             -- ayuda específica a un comando\n"
+" roundup-admin help all                   -- toda la ayuda disponible\n"
+
+#: ../roundup/admin.py:138
+msgid "Commands:"
+msgstr "Comandos:"
+
+#: ../roundup/admin.py:145
+msgid ""
+"Commands may be abbreviated as long as the abbreviation\n"
+"matches only one command, e.g. l == li == lis == list."
+msgstr ""
+"Los comandos pueden ser abreviados siempre y cuando la abreviación\n"
+"coincida con sólo un comando, ej. l == li == lis == list."
+
+#: ../roundup/admin.py:175
+msgid ""
+"\n"
+"All commands (except help) require a tracker specifier. This is just\n"
+"the path to the roundup tracker you're working with. A roundup tracker\n"
+"is where roundup keeps the database and configuration file that defines\n"
+"an issue tracker. It may be thought of as the issue tracker's \"home\n"
+"directory\". It may be specified in the environment variable TRACKER_HOME\n"
+"or on the command line as \"-i tracker\".\n"
+"\n"
+"A designator is a classname and a nodeid concatenated, eg. bug1, user10, ...\n"
+"\n"
+"Property values are represented as strings in command arguments and in the\n"
+"printed results:\n"
+" . Strings are, well, strings.\n"
+" . Date values are printed in the full date format in the local time zone,\n"
+"   and accepted in the full format or any of the partial formats explained\n"
+"   below.\n"
+" . Link values are printed as node designators. When given as an argument,\n"
+"   node designators and key strings are both accepted.\n"
+" . Multilink values are printed as lists of node designators joined\n"
+"   by commas.  When given as an argument, node designators and key\n"
+"   strings are both accepted; an empty string, a single node, or a list\n"
+"   of nodes joined by commas is accepted.\n"
+"\n"
+"When property values must contain spaces, just surround the value with\n"
+"quotes, either ' or \". A single space may also be backslash-quoted. If a\n"
+"value must contain a quote character, it must be backslash-quoted or inside\n"
+"quotes. Examples:\n"
+"           hello world      (2 tokens: hello, world)\n"
+"           \"hello world\"    (1 token: hello world)\n"
+"           \"Roch'e\" Compaan (2 tokens: Roch'e Compaan)\n"
+"           Roch\\'e Compaan  (2 tokens: Roch'e Compaan)\n"
+"           address=\"1 2 3\"  (1 token: address=1 2 3)\n"
+"           \\\\               (1 token: \\)\n"
+"           \\n"
+"\\r\\t           (1 token: a newline, carriage-return and tab)\n"
+"\n"
+"When multiple nodes are specified to the roundup get or roundup set\n"
+"commands, the specified properties are retrieved or set on all the listed\n"
+"nodes.\n"
+"\n"
+"When multiple results are returned by the roundup get or roundup find\n"
+"commands, they are printed one per line (default) or joined by commas (with\n"
+"the -c) option.\n"
+"\n"
+"Where the command changes data, a login name/password is required. The\n"
+"login may be specified as either \"name\" or \"name:password\".\n"
+" . ROUNDUP_LOGIN environment variable\n"
+" . the -u command-line option\n"
+"If either the name or password is not supplied, they are obtained from the\n"
+"command-line.\n"
+"\n"
+"Date format examples:\n"
+"  \"2000-04-17.03:45\" means <Date 2000-04-17.08:45:00>\n"
+"  \"2000-04-17\" means <Date 2000-04-17.00:00:00>\n"
+"  \"01-25\" means <Date yyyy-01-25.00:00:00>\n"
+"  \"08-13.22:13\" means <Date yyyy-08-14.03:13:00>\n"
+"  \"11-07.09:32:43\" means <Date yyyy-11-07.14:32:43>\n"
+"  \"14:25\" means <Date yyyy-mm-dd.19:25:00>\n"
+"  \"8:47:11\" means <Date yyyy-mm-dd.13:47:11>\n"
+"  \".\" means \"right now\"\n"
+"\n"
+"Command help:\n"
+msgstr ""
+"\n"
+"Todos los comandos (excepto ayuda) requieren un especificador de tracker. Este es\n"
+"simplemente la ruta al tracker roundup con el que se está trabajando. Un\n"
+"tracker roundup es donde roundup mantiene la base de datos y el archivo de\n"
+"configuración que define un issue tracker. Puede pensarse en el mismo como el\n"
+"\"directorio personal\" del issue tracker. Puede especificarse en la variable\n"
+"de entorno TRACKER_HOME o en la línea de comandos como \"-i tracker\".\n"
+"\n"
+"Un designador es un nombre de clase y un id de nodo concatenados, ej. bug1, user10, ...\n"
+"\n"
+"Los valores de propiedades se representan como cadenas en argumentos de comandos y en\n"
+"resultados visualizados:\n"
+" . Las cadenas son, ahem, cadenas.\n"
+" . Los valores de fechas se imprimen en el formato de fecha completo y en el huso\n"
+"   horario local y se aceptan en el formato completo o cualquiera de los\n"
+"   formatos parciales descriptos mas abajo.\n"
+" . Los valores Link se imprimen como designadores de nodos. Cuando se pasan como\n"
+"   argumentos, se aceptan tanto los designadores de nodos como las cadenas clave.\n"
+" . Los valores Multilink se imprimen como listas de designadores de nodos unidos por\n"
+"   comas. Cuando se pasan como argumentos, se aceptan tanto los designadores de\n"
+"   nodos como las cadenas clave; tambien se aceptan una cadena vacía, un nodo\n"
+"   individual, o una lista de nodos unidos por comas.\n"
+"\n"
+"Cuando los valores de las propiedades deben contener espacios, simplemente\n"
+"escriba el valor entre comillas simples (') o dobles (\"). Un caracter espacio\n"
+"individual puede ser tambien representado prefijándolo con un caracter barra\n"
+"invertida (\\). Si un valor debe incluir un caracter comillas, debe prefijarse\n"
+"el mismo con un caracter barra invertida o escribirse entre comillas. Ejemplos:\n"
+"           hello world      (2 unidades: hello, world)\n"
+"           \"hello world\"    (1 unidad: hello world)\n"
+"           \"Roch'e\" Compaan (2 unidades: Roch'e Compaan)\n"
+"           Roch\\'e Compaan  (2 unidades: Roch'e Compaan)\n"
+"           address=\"1 2 3\"  (1 unidad: address=1 2 3)\n"
+"           \\\\               (1 unidad: \\)\n"
+"           \\n"
+"\\r\\t           (1 unidad: una línea nueva, retorno de carro y tabulación)\n"
+"\n"
+"Cuando se especifican múltiples nodos a los comandos roundup get y roundup set,\n"
+"los valores de las propiedades especificadas son recuperados o asignados en\n"
+"todos los nodos listados.\n"
+"\n"
+"Cuando los comandos roundup get o roundup find retornan múltiples resultados\n"
+"los mismos son impresos uno por línea (comportamiento por omisión) o unidos\n"
+"por comas (con la opción -c).\n"
+"\n"
+"Cuando un comando modifica algún dato, es obligatorio el uso de nombre de\n"
+"usuario/contraseña. Esta información puede especificarse como \"nombre\" o\n"
+"\"nombre:contraseña\" en\n"
+" . La variable de entorno ROUNDUP_LOGIN\n"
+" . La opción de línea de comandos -u\n"
+"Si no se proveen ya sea el nombre de usuario o la contraseña, los mismos se\n"
+"obtendrán de la línea de comandos.\n"
+"\n"
+"Ejemplos de formatos de fecha:\n"
+"  \"2000-04-17.03:45\" significa <Date 2000-04-17.08:45:00>\n"
+"  \"2000-04-17\" significa <Date 2000-04-17.00:00:00>\n"
+"  \"01-25\" significa <Date yyyy-01-25.00:00:00>\n"
+"  \"08-13.22:13\" significa <Date yyyy-08-14.03:13:00>\n"
+"  \"11-07.09:32:43\" significa <Date yyyy-11-07.14:32:43>\n"
+"  \"14:25\" significa <Date yyyy-mm-dd.19:25:00>\n"
+"  \"8:47:11\" significa <Date yyyy-mm-dd.13:47:11>\n"
+"  \".\" significa \"right now\"\n"
+"\n"
+"Ayuda del comando:\n"
+
+#: ../roundup/admin.py:238
+#, python-format
+msgid "%s:"
+msgstr ""
+
+#: ../roundup/admin.py:243
+msgid ""
+"Usage: help topic\n"
+"        Give help about topic.\n"
+"\n"
+"        commands  -- list commands\n"
+"        <command> -- help specific to a command\n"
+"        initopts  -- init command options\n"
+"        all       -- all available help\n"
+"        "
+msgstr ""
+"Uso: help tópico\n"
+"      Visualiza ayuda acerca del tópico.\n"
+"\n"
+"      commands  -- lista los comandos\n"
+"      <comando> -- ayuda específica a un comando\n"
+"      initopts  -- opciones del comando init\n"
+"      all       -- toda la ayuda disponible\n"
+"      "
+
+#: ../roundup/admin.py:266
+#, python-format
+msgid "Sorry, no help for \"%(topic)s\""
+msgstr "Lo siento, no hay ayuda para \"%(topic)s\""
+
+# ../roundup/admin.py:338 :387
+#: ../roundup/admin.py:338
+#: ../roundup/admin.py:387
+msgid "Templates:"
+msgstr "Plantillas:"
+
+# ../roundup/admin.py:341 :398
+#: ../roundup/admin.py:341
+#: ../roundup/admin.py:398
+msgid "Back ends:"
+msgstr "Motor de almacenamiento"
+
+#: ../roundup/admin.py:344
+msgid ""
+"Usage: install [template [backend [admin password]]]\n"
+"        Install a new Roundup tracker.\n"
+"\n"
+"        The command will prompt for the tracker home directory\n"
+"        (if not supplied through TRACKER_HOME or the -i option).\n"
+"        The template, backend and admin password may be specified\n"
+"        on the command-line as arguments, in that order.\n"
+"\n"
+"        The initialise command must be called after this command in order\n"
+"        to initialise the tracker's database. You may edit the tracker's\n"
+"        initial database contents before running that command by editing\n"
+"        the tracker's dbinit.py module init() function.\n"
+"\n"
+"        See also initopts help.\n"
+"        "
+msgstr ""
+"Uso: install [plantilla [backend [contraseña admin]]]\n"
+"      Instala un nuevo tracker Roundup.\n"
+"\n"
+"      El comando preguntará el directorio base del tracker\n"
+"      (si el mismo no se provee vía TRACKER_HOME o la opción -i).\n"
+"      La plantilla, el backend y la contraseña de admin pueden especificarse\n"
+"      en la línea de comandos como argumentos, en ese orden.\n"
+"\n"
+"      Luego de este comando debe usarse el comando initialise con el objetivo\n"
+"      de inicializar la base de datos del tracker. Ud. puede editar los contenidos\n"
+"      iniciales de la base de datos del tracker antes de ejecutar dicho comando\n"
+"      editando la funcion init() del módulo dbinit.py del tracker.\n"
+"\n"
+"      Vea también initopts help.\n"
+"      "
+
+# ../roundup/admin.py:360 :442 :503 :582 :632 :688 :709 :737 :808 :875 :946
+# :994 :1016 :1043 :1106 :1173
+#: ../roundup/admin.py:360
+#: ../roundup/admin.py:447
+#: ../roundup/admin.py:508
+#: ../roundup/admin.py:587
+#: ../roundup/admin.py:637
+#: ../roundup/admin.py:695
+#: ../roundup/admin.py:716
+#: ../roundup/admin.py:744
+#: ../roundup/admin.py:815
+#: ../roundup/admin.py:882
+#: ../roundup/admin.py:953
+#: ../roundup/admin.py:1001
+#: ../roundup/admin.py:1023
+#: ../roundup/admin.py:1050
+#: ../roundup/admin.py:1117
+#: ../roundup/admin.py:1184
+msgid "Not enough arguments supplied"
+msgstr "No se proveyó una cantidad suficiente de argumentos"
+
+#: ../roundup/admin.py:366
+#, python-format
+msgid "Instance home parent directory \"%(parent)s\" does not exist"
+msgstr "El directorio padre \"%(parent)s\" del directorio base de la instancia no existe"
+
+#: ../roundup/admin.py:374
+#, python-format
+msgid ""
+"WARNING: There appears to be a tracker in \"%(tracker_home)s\"!\n"
+"If you re-install it, you will lose all the data!\n"
+"Erase it? Y/N: "
+msgstr ""
+"ATENCIÓN: Aparentemente ya existe un tracker en \"%(tracker_home)s\"!\n"
+"Si Ud. lo reinstala, perderá toda la información relacionada al mismo!\n"
+"Elimino la misma? Y/N: "
+
+#: ../roundup/admin.py:389
+msgid "Select template [classic]: "
+msgstr "Seleccione la plantilla [classic]: "
+
+#: ../roundup/admin.py:400
+msgid "Select backend [anydbm]: "
+msgstr "Selecccione el motor de almacenamiento [anydbm]: "
+
+#: ../roundup/admin.py:409
+#, python-format
+msgid ""
+"\n"
+"---------------------------------------------------------------------------\n"
+" You should now edit the tracker configuration file:\n"
+"   %(config_file)s"
+msgstr ""
+"\n"
+"---------------------------------------------------------------------------\n"
+" Ud. debe ahora editar el archivo de configuración del tracker:\n"
+"   %(config_file)s"
+
+#: ../roundup/admin.py:419
+msgid " ... at a minimum, you must set following options:"
+msgstr " ... como mínimo, debe configurar las siguientes opciones:"
+
+#: ../roundup/admin.py:424
+#, python-format
+msgid ""
+"\n"
+" If you wish to modify the database schema,\n"
+" you should also edit the schema file:\n"
+"   %(database_config_file)s\n"
+" You may also change the database initialisation file:\n"
+"   %(database_init_file)s\n"
+" ... see the documentation on customizing for more information.\n"
+"\n"
+" You MUST run the \"roundup-admin initialise\" command once you've performed\n"
+" the above steps.\n"
+"---------------------------------------------------------------------------\n"
+msgstr ""
+"\n"
+" Si desea modificar el esquema de la base de datos,\n"
+" debe tambien editar el archivo de esquema:\n"
+"   %(database_config_file)s\n"
+" Puede también cambiar el archivo de inicialización de la base de datos:\n"
+"   %(database_init_file)s\n"
+" ... vea la documentación sobre personalización si desea más información.\n"
+"\n"
+" Ud. DEBE ejecutar el comando \"roundup-admin initialise\" una vez que haya\n"
+" completado los pasos arriba descriptos.\n"
+"---------------------------------------------------------------------------\n"
+
+#: ../roundup/admin.py:442
+msgid ""
+"Usage: genconfig <filename>\n"
+"        Generate a new tracker config file (ini style) with default values\n"
+"        in <filename>.\n"
+"        "
+msgstr ""
+"Uso: genconfig <archivo>\n"
+"      Genera un nuevo archivo de configuración de tracker (en formato ini) con\n"
+"      valores por defecto en el archivo <archivo>.\n"
+"      "
+
+#. password
+#: ../roundup/admin.py:452
+msgid ""
+"Usage: initialise [adminpw]\n"
+"        Initialise a new Roundup tracker.\n"
+"\n"
+"        The administrator details will be set at this step.\n"
+"\n"
+"        Execute the tracker's initialisation function dbinit.init()\n"
+"        "
+msgstr ""
+"Uso: initialise [contraseña-admin]\n"
+"      Inicializa un nuevo tracker Roundup.\n"
+"\n"
+"      Es en este paso cuando se configuran los detalles del usuario administrador.\n"
+"\n"
+"      Ejecuta la función de inicialización dbinit.init() del tracker\n"
+"      "
+
+#: ../roundup/admin.py:466
+msgid "Admin Password: "
+msgstr "Contraseña de administración: "
+
+#: ../roundup/admin.py:467
+msgid "       Confirm: "
+msgstr "       Confirmar: "
+
+#: ../roundup/admin.py:471
+msgid "Instance home does not exist"
+msgstr "El directorio base de la instancia no existe"
+
+#: ../roundup/admin.py:475
+msgid "Instance has not been installed"
+msgstr "La instancia no ha sido instalada"
+
+#: ../roundup/admin.py:480
+msgid ""
+"WARNING: The database is already initialised!\n"
+"If you re-initialise it, you will lose all the data!\n"
+"Erase it? Y/N: "
+msgstr ""
+"ATENCIÓN: La base de datos ya ha sido inicializada!\n"
+"Si la reinicializa, perderá toda la información!\n"
+"Eliminar la misma? Y/N: "
+
+#: ../roundup/admin.py:501
+msgid ""
+"Usage: get property designator[,designator]*\n"
+"        Get the given property of one or more designator(s).\n"
+"\n"
+"        Retrieves the property value of the nodes specified\n"
+"        by the designators.\n"
+"        "
+msgstr ""
+"Uso: get propiedad designador[,designador]*\n"
+"      Retorna la propiedad especificada de uno o mas designadores.\n"
+"\n"
+"      Recupera el valor de la propiedad de los nodos especificados\n"
+"      por los designadores.\n"
+"      "
+
+# ../roundup/admin.py:536 :551
+#: ../roundup/admin.py:541
+#: ../roundup/admin.py:556
+#, python-format
+msgid "property %s is not of type Multilink or Link so -d flag does not apply."
+msgstr "la propiededad %s no es de tipo Multilink o Link asi que el modificador -d no puede usarse."
+
+# ../roundup/admin.py:559 :957 :1006 :1028
+#: ../roundup/admin.py:564
+#: ../roundup/admin.py:964
+#: ../roundup/admin.py:1013
+#: ../roundup/admin.py:1035
+#, python-format
+msgid "no such %(classname)s node \"%(nodeid)s\""
+msgstr "no existe nodo de clase %(classname)s llamado  \"%(nodeid)s\""
+
+#: ../roundup/admin.py:566
+#, python-format
+msgid "no such %(classname)s property \"%(propname)s\""
+msgstr "no existe propiedad de clase %(classname)s llamado  \"%(propname)s\""
+
+#: ../roundup/admin.py:575
+msgid ""
+"Usage: set items property=value property=value ...\n"
+"        Set the given properties of one or more items(s).\n"
+"\n"
+"        The items are specified as a class or as a comma-separated\n"
+"        list of item designators (ie \"designator[,designator,...]\").\n"
+"\n"
+"        This command sets the properties to the values for all designators\n"
+"        given. If the value is missing (ie. \"property=\") then the property\n"
+"        is un-set. If the property is a multilink, you specify the linked\n"
+"        ids for the multilink as comma-separated numbers (ie \"1,2,3\").\n"
+"        "
+msgstr ""
+"Uso: set items propiedad=valor propiedad=valor ...\n"
+"      Establece las propiedades especificadas de uno o más ítems.\n"
+"\n"
+"      Los ítems se especifican como una clase o como una lista de designadores\n"
+"      de ítems (\"designador[,designador,...]\") separados por comas.\n"
+"\n"
+"      Este comando establece valores de las propiedades para todos los\n"
+"      designadores especificados. Si los valores no se especifican (\"propiedad=\")\n"
+"      entonces la propiedad se elimina. Si la propiedad es del tipo multilink, deben\n"
+"      especificarse los identificadores asociados como números separados por comas\n"
+"      (\"1,2,3\").\n"
+"      "
+
+#: ../roundup/admin.py:629
+msgid ""
+"Usage: find classname propname=value ...\n"
+"        Find the nodes of the given class with a given link property value.\n"
+"\n"
+"        Find the nodes of the given class with a given link property value.\n"
+"        The value may be either the nodeid of the linked node, or its key\n"
+"        value.\n"
+"        "
+msgstr ""
+"Uso: find nombreclase nombreprop=valor ...\n"
+"      Busca los nodos de la clase especificada que poseen un cierto valor de propiedad.\n"
+"\n"
+"      Busca los nodos de la clase especificada que poseen un cierto valor de propiedad.\n"
+"      El valor puede ser el identificador de nodo del nodo enlazado o su valor clave.\n"
+"      "
+
+# ../roundup/admin.py:675 :828 :840 :894
+#: ../roundup/admin.py:682
+#: ../roundup/admin.py:835
+#: ../roundup/admin.py:847
+#: ../roundup/admin.py:901
+#, python-format
+msgid "%(classname)s has no property \"%(propname)s\""
+msgstr "%(classname)s no posee la propiedad \"%(propname)s\""
+
+#: ../roundup/admin.py:689
+msgid ""
+"Usage: specification classname\n"
+"        Show the properties for a classname.\n"
+"\n"
+"        This lists the properties for a given class.\n"
+"        "
+msgstr ""
+"Uso: specification nombreclase\n"
+"      Muestra las propiedades para un nombre de clase.\n"
+"\n"
+"      Visualiza las propiedades para una cierta clase.\n"
+"      "
+
+#: ../roundup/admin.py:704
+#, python-format
+msgid "%(key)s: %(value)s (key property)"
+msgstr "%(key)s: %(value)s (propiedad de clave)"
+
+#: ../roundup/admin.py:706
+#, python-format
+msgid "%(key)s: %(value)s"
+msgstr ""
+
+#: ../roundup/admin.py:709
+msgid ""
+"Usage: display designator[,designator]*\n"
+"        Show the property values for the given node(s).\n"
+"\n"
+"        This lists the properties and their associated values for the given\n"
+"        node.\n"
+"        "
+msgstr ""
+"Uso: display designador[,designador]*\n"
+"      Muestra los valores de propiedeades para el/los nodo(s) especificados(s).\n"
+"\n"
+"      Lista las propiedades y sus valores asociados para el nodo especificado.\n"
+"      "
+
+#: ../roundup/admin.py:733
+#, python-format
+msgid "%(key)s: %(value)r"
+msgstr ""
+
+#: ../roundup/admin.py:736
+msgid ""
+"Usage: create classname property=value ...\n"
+"        Create a new entry of a given class.\n"
+"\n"
+"        This creates a new entry of the given class using the property\n"
+"        name=value arguments provided on the command line after the \"create\"\n"
+"        command.\n"
+"        "
+msgstr ""
+"Uso: create nombreclase propiedad=valor ...\n"
+"      Crea una nueva entrada de una clase especificada.\n"
+"\n"
+"      Crea una nueva entrada de la clase especificada usando los argumentos nombre=valor\n"
+"      provistos en la línea de comandos luego del comando \"create\" para establecer\n"
+"      valores de propiedad(es).      "
+
+#: ../roundup/admin.py:763
+#, python-format
+msgid "%(propname)s (Password): "
+msgstr "%(propname)s (Contraseña): "
+
+#: ../roundup/admin.py:765
+#, python-format
+msgid "   %(propname)s (Again): "
+msgstr "   %(propname)s (Nuevamente): "
+
+#: ../roundup/admin.py:767
+msgid "Sorry, try again..."
+msgstr "Lo lamento, intente nuevamente..."
+
+#: ../roundup/admin.py:771
+#, python-format
+msgid "%(propname)s (%(proptype)s): "
+msgstr ""
+
+#: ../roundup/admin.py:789
+#, python-format
+msgid "you must provide the \"%(propname)s\" property."
+msgstr "debe proveer la propiedad \"%(propname)s\"."
+
+#: ../roundup/admin.py:800
+msgid ""
+"Usage: list classname [property]\n"
+"        List the instances of a class.\n"
+"\n"
+"        Lists all instances of the given class. If the property is not\n"
+"        specified, the  \"label\" property is used. The label property is\n"
+"        tried in order: the key, \"name\", \"title\" and then the first\n"
+"        property, alphabetically.\n"
+"\n"
+"        With -c, -S or -s print a list of item id's if no property\n"
+"        specified.  If property specified, print list of that property\n"
+"        for every class instance.\n"
+"        "
+msgstr ""
+"Uso: list nombreclase [propiedad]\n"
+"      Lista las instancias de una clase.\n"
+"\n"
+"      Lista todas las instancias de la clase especificada. Si no se\n"
+"      especifica la propiedad, se usará la propiedad \"etiqueta\". La propiedad\n"
+"      etiqueta es obtenida siguiendo el siguiente orden: \"name\", \"title\"\n"
+"      y luego la primera propiedad en orden alfabético.\n"
+"\n"
+"      Cuando se usa -c, -S o -s imprime una lista de ids de items si no se ha especificado\n"
+"      propiedad. Si se ha especificado propiedad, imprime una lista de dicha propiedad\n"
+"      para cada instancia de la clase.\n"
+"      "
+
+#: ../roundup/admin.py:813
+msgid "Too many arguments supplied"
+msgstr "Demasiados argumentos"
+
+#: ../roundup/admin.py:849
+#, python-format
+msgid "%(nodeid)4s: %(value)s"
+msgstr ""
+
+#: ../roundup/admin.py:853
+msgid ""
+"Usage: table classname [property[,property]*]\n"
+"        List the instances of a class in tabular form.\n"
+"\n"
+"        Lists all instances of the given class. If the properties are not\n"
+"        specified, all properties are displayed. By default, the column\n"
+"        widths are the width of the largest value. The width may be\n"
+"        explicitly defined by defining the property as \"name:width\".\n"
+"        For example::\n"
+"\n"
+"          roundup> table priority id,name:10\n"
+"          Id Name\n"
+"          1  fatal-bug\n"
+"          2  bug\n"
+"          3  usability\n"
+"          4  feature\n"
+"\n"
+"        Also to make the width of the column the width of the label,\n"
+"        leave a trailing : without a width on the property. For example::\n"
+"\n"
+"          roundup> table priority id,name:\n"
+"          Id Name\n"
+"          1  fata\n"
+"          2  bug\n"
+"          3  usab\n"
+"          4  feat\n"
+"\n"
+"        will result in a the 4 character wide \"Name\" column.\n"
+"        "
+msgstr ""
+"Uso: table nombreclase [propiedad[,propiedad]*]\n"
+"      Lista las instancias de una clase en formato tabular.\n"
+"\n"
+"      Lista todas las instancias de la clase especificada. Si no se especifican\n"
+"      las propiedades, se visualizan todas las propiedades. Por omisión, los anchos\n"
+"      de las columnas son iguales a los anchos de valores mayores respectivos. Es\n"
+"      posible definir el ancho de columna de una propiedad definiendo la misma en la\n"
+"      forma \"nombre:ancho\".\n"
+"      Por ejemplo::\n"
+"\n"
+"        roundup> table priority id,name:10\n"
+"        Id Name\n"
+"        1  fatal-bug\n"
+"        2  bug\n"
+"        3  usability\n"
+"        4  feature\n"
+"\n"
+"      también, para obtener un ancho de columna igual al ancho de la etiqueta,\n"
+"      deje un : al final sin un valor de ancho de la propiedad. Por ejemplo::\n"
+"\n"
+"        roundup> table priority id,name:\n"
+"        Id Name\n"
+"        1  fata\n"
+"        2  bug\n"
+"        3  usab\n"
+"        4  feat\n"
+"\n"
+"      dará como resultado una columna \"Name\" con un ancho de 4 caracteres.\n"
+"      "
+
+#: ../roundup/admin.py:897
+#, python-format
+msgid "\"%(spec)s\" not name:width"
+msgstr "\"%(spec)s\" no es de la forma nombre:longitud"
+
+#: ../roundup/admin.py:947
+msgid ""
+"Usage: history designator\n"
+"        Show the history entries of a designator.\n"
+"\n"
+"        Lists the journal entries for the node identified by the designator.\n"
+"        "
+msgstr ""
+"Uso: history designador\n"
+"      Muestra las entradas en la historia de un designador.\n"
+"\n"
+"      Lista las entradas del journal para el nodo identificado por el designador.\n"
+"      "
+
+#: ../roundup/admin.py:968
+msgid ""
+"Usage: commit\n"
+"        Commit changes made to the database during an interactive session.\n"
+"\n"
+"        The changes made during an interactive session are not\n"
+"        automatically written to the database - they must be committed\n"
+"        using this command.\n"
+"\n"
+"        One-off commands on the command-line are automatically committed if\n"
+"        they are successful.\n"
+"        "
+msgstr ""
+"Uso: commit\n"
+"      Almacena definitivamente cambios realizados a la base de datos durante\n"
+"      una sesión interactiva.\n"
+"\n"
+"      Los cambios realizados durante una sesión interactiva no son automáticamente\n"
+"      escritos en la base de datos - los mismos deben ser escritos usando este comando\n"
+"\n"
+"      Comandos individuales realizados desde la línea de comandos son automáticamente\n"
+"      escritos si resultan exitosos.\n"
+"      "
+
+#: ../roundup/admin.py:982
+msgid ""
+"Usage: rollback\n"
+"        Undo all changes that are pending commit to the database.\n"
+"\n"
+"        The changes made during an interactive session are not\n"
+"        automatically written to the database - they must be committed\n"
+"        manually. This command undoes all those changes, so a commit\n"
+"        immediately after would make no changes to the database.\n"
+"        "
+msgstr ""
+"Uso: rollback\n"
+"      Deshace todos los cambios que están pendientes de ser escritos\n"
+"      definitivamente en la base de datos.\n"
+"\n"
+"      Los cambios hechos durante una sesión interactiva no son automáticamente\n"
+"      escritos en la base de datos - los mismos deben ser grabados manualmente.\n"
+"      Este comando deshace todos dichos cambios, de manera que un comando commit\n"
+"      inmediatamente posterior no introduciría cambios en la base de datos.\n"
+"      "
+
+#: ../roundup/admin.py:994
+msgid ""
+"Usage: retire designator[,designator]*\n"
+"        Retire the node specified by designator.\n"
+"\n"
+"        This action indicates that a particular node is not to be retrieved\n"
+"        by the list or find commands, and its key value may be re-used.\n"
+"        "
+msgstr ""
+"Uso: retire designador[,designador]*\n"
+"      Retira el nodo especificado por designador.\n"
+"\n"
+"      Esta acción indica que un nodo particular no se obtendrá cuando se\n"
+"      usen los comandos list y find, y que su valor clave podrá ser reusado.\n"
+"      "
+
+#: ../roundup/admin.py:1017
+msgid ""
+"Usage: restore designator[,designator]*\n"
+"        Restore the retired node specified by designator.\n"
+"\n"
+"        The given nodes will become available for users again.\n"
+"        "
+msgstr ""
+"Uso: restore designador[,designador]*\n"
+"      Restaura el nodo retirado especificado por designador.\n"
+"\n"
+"      Los nodos especificados volverán a estar nuevamente disponibles para los usuarios.\n"
+"      "
+
+#. grab the directory to export to
+#: ../roundup/admin.py:1039
+msgid ""
+"Usage: export [class[,class]] export_dir\n"
+"        Export the database to colon-separated-value files.\n"
+"\n"
+"        Optionally limit the export to just the names classes.\n"
+"\n"
+"        This action exports the current data from the database into\n"
+"        colon-separated-value files that are placed in the nominated\n"
+"        destination directory.\n"
+"        "
+msgstr ""
+"Uso: export [clase[,clase]] export_dir\n"
+"      Exporta la base de datos a archivos de valores separados por comas.\n"
+"\n"
+"      Opcionalmente limita la exportación sólo a las clases especificadas.\n"
+"\n"
+"      Esta acción exporta los datos actuales desde la base de datos a\n"
+"      archivos de valores separados por comas que se colocarán en el directorio\n"
+"      de destino especificado (export_dir).\n"
+"      "
+
+#: ../roundup/admin.py:1097
+msgid ""
+"Usage: import import_dir\n"
+"        Import a database from the directory containing CSV files,\n"
+"        two per class to import.\n"
+"\n"
+"        The files used in the import are:\n"
+"\n"
+"        <class>.csv\n"
+"          This must define the same properties as the class (including\n"
+"          having a \"header\" line with those property names.)\n"
+"        <class>-journals.csv\n"
+"          This defines the journals for the items being imported.\n"
+"\n"
+"        The imported nodes will have the same nodeid as defined in the\n"
+"        import file, thus replacing any existing content.\n"
+"\n"
+"        The new nodes are added to the existing database - if you want to\n"
+"        create a new database using the imported data, then create a new\n"
+"        database (or, tediously, retire all the old data.)\n"
+"        "
+msgstr ""
+"Uso: import import_dir\n"
+"      Importa una base de datos desde el directorio conteniendo archivos CSV,\n"
+"      dos por cada clase a importar.\n"
+"\n"
+"      Los archivos usados en la importación son::\n"
+"\n"
+"      <clase>.csv\n"
+"        Este debe definir las mismas propiedades que la clase (esto incluye la\n"
+"        existencia de una línea \"encabezado\" con los nombre de dichas propiedades.)\n"
+"      <clase>-journals.csv\n"
+"        Este define los journals para los items que se están importando.\n"
+"\n"
+"        Los nodos importados tendrán los mismos id´s que los nodos según se encontraban\n"
+"        definidos en el arvhivo importado, por lo tanto reemplazarán todo contenido\n"
+"        preexistente.\n"
+"\n"
+"        Los nuevos nodos son agregados a la base de datos existente - si Ud. desea\n"
+"        crear una base de datos nueva usando los datos importados, entonces puede\n"
+"        crear una nueva base de datos (o, tediosamente, retirar toda los datos viejos.)\n"
+"        "
+
+#: ../roundup/admin.py:1166
+msgid ""
+"Usage: pack period | date\n"
+"\n"
+"        Remove journal entries older than a period of time specified or\n"
+"        before a certain date.\n"
+"\n"
+"        A period is specified using the suffixes \"y\", \"m\", and \"d\". The\n"
+"        suffix \"w\" (for \"week\") means 7 days.\n"
+"\n"
+"              \"3y\" means three years\n"
+"              \"2y 1m\" means two years and one month\n"
+"              \"1m 25d\" means one month and 25 days\n"
+"              \"2w 3d\" means two weeks and three days\n"
+"\n"
+"        Date format is \"YYYY-MM-DD\" eg:\n"
+"            2001-01-01\n"
+"\n"
+"        "
+msgstr ""
+"Uso: pack período |fecha\n"
+"\n"
+"      Elimina entradas de journal mas viejas que un período de tiempo\n"
+"      especificado o anteriores a cierta fecha.\n"
+"\n"
+"      Un período se especifica usando los sufijos \"y\", \"m\", and \"d\". El\n"
+"      sufijo \"w\" (por \"week\") significa 7 días.\n"
+"\n"
+"            \"3y\" significa tres años\n"
+"            \"2y 1m\" significa dos años y un mes\n"
+"            \"1m 25d\" significa un mes y 25 días\n"
+"            \"2w 3d\" significa dos semanas y tres días\n"
+"\n"
+"      El formato de fecha es \"YYYY-MM-DD\" ej.:\n"
+"          2001-01-01\n"
+"\n"
+"      "
+
+#: ../roundup/admin.py:1194
+msgid "Invalid format"
+msgstr "Formato inválido"
+
+#: ../roundup/admin.py:1204
+msgid ""
+"Usage: reindex [classname|designator]*\n"
+"        Re-generate a tracker's search indexes.\n"
+"\n"
+"        This will re-generate the search indexes for a tracker.\n"
+"        This will typically happen automatically.\n"
+"        "
+msgstr ""
+"Uso: reindex [nombreclase|designador]*\n"
+"      Regenera los índices de búsqueda de un tracker.\n"
+"\n"
+"      Este comando regenerará los índices de búsqueda de un tracker.\n"
+"      Es un comando que por lo general se ejecuta automáticamente.\n"
+"      "
+
+#: ../roundup/admin.py:1218
+#, python-format
+msgid "no such item \"%(designator)s\""
+msgstr "no existe un ítem llamado \"%(designator)s\""
+
+#: ../roundup/admin.py:1228
+msgid ""
+"Usage: security [Role name]\n"
+"        Display the Permissions available to one or all Roles.\n"
+"        "
+msgstr ""
+"Uso: security [Nombre de rol]\n"
+"      Muestra los permisos disponibles para uno o todos los Roles.\n"
+"      "
+
+#: ../roundup/admin.py:1236
+#, python-format
+msgid "No such Role \"%(role)s\""
+msgstr "No existe un Rol llamado \"%(role)s\""
+
+#: ../roundup/admin.py:1242
+#, python-format
+msgid "New Web users get the Roles \"%(role)s\""
+msgstr "Los nuevos usuarios creados vía Web obtiene los Roles \"%(role)s\""
+
+#: ../roundup/admin.py:1244
+#, python-format
+msgid "New Web users get the Role \"%(role)s\""
+msgstr "Los nuevos usuarios creados vía Web obtienen el Rol \"%(role)s\""
+
+#: ../roundup/admin.py:1247
+#, python-format
+msgid "New Email users get the Roles \"%(role)s\""
+msgstr "Los nuevos usuarios creados vía e-mail obtienen los Roles  \"%(role)s\""
+
+#: ../roundup/admin.py:1249
+#, python-format
+msgid "New Email users get the Role \"%(role)s\""
+msgstr "Los nuevos usuarios creados vía e-mail obtienen el Rol \"%(role)s\""
+
+#: ../roundup/admin.py:1252
+#, python-format
+msgid "Role \"%(name)s\":"
+msgstr "Rol \"%(name)s\":"
+
+#: ../roundup/admin.py:1257
+#, python-format
+msgid " %(description)s (%(name)s for \"%(klass)s\": %(properties)s only)"
+msgstr " %(description)s (%(name)s para \"%(klass)s\": %(properties)s solamente)"
+
+#: ../roundup/admin.py:1260
+#, python-format
+msgid " %(description)s (%(name)s for \"%(klass)s\" only)"
+msgstr " %(description)s (%(name)s para \"%(klass)s\" solamente)"
+
+#: ../roundup/admin.py:1263
+#, python-format
+msgid " %(description)s (%(name)s)"
+msgstr ""
+
+#: ../roundup/admin.py:1292
+#, python-format
+msgid "Unknown command \"%(command)s\" (\"help commands\" for a list)"
+msgstr "Comando desconocido \"%(command)s\" (tipee \"help commands\" para obtener una lista)"
+
+#: ../roundup/admin.py:1298
+#, python-format
+msgid "Multiple commands match \"%(command)s\": %(list)s"
+msgstr "Coinciden mas de un comando \"%(command)s\": %(list)s"
+
+#: ../roundup/admin.py:1305
+msgid "Enter tracker home: "
+msgstr "Ingrese directorio base del tracker: "
+
+# ../roundup/admin.py:1296 :1302 :1322
+#: ../roundup/admin.py:1312
+#: ../roundup/admin.py:1318
+#: ../roundup/admin.py:1338
+#, python-format
+msgid "Error: %(message)s"
+msgstr ""
+
+#: ../roundup/admin.py:1326
+#, python-format
+msgid "Error: Couldn't open tracker: %(message)s"
+msgstr "Error: No se pudo abrir el tracker: %(message)s"
+
+#: ../roundup/admin.py:1351
+#, python-format
+msgid ""
+"Roundup %s ready for input.\n"
+"Type \"help\" for help."
+msgstr ""
+"Roundup %s listo para comandos.\n"
+"Tipee \"help\" para ayuda."
+
+#: ../roundup/admin.py:1356
+msgid "Note: command history and editing not available"
+msgstr "Nota: historia y edición de comandos no disponible"
+
+#: ../roundup/admin.py:1360
+msgid "roundup> "
+msgstr ""
+
+#: ../roundup/admin.py:1362
+msgid "exit..."
+msgstr "salir..."
+
+#: ../roundup/admin.py:1372
+msgid "There are unsaved changes. Commit them (y/N)? "
+msgstr "Hay cambios sin guardar. Debo guardar los mismos (y/N)? "
+
+#: ../roundup/backends/back_anydbm.py:2061
+#, python-format
+msgid "WARNING: invalid date tuple %r"
+msgstr "ATENCIÓN: tuple de fecha inválido %r"
+
+#: ../roundup/backends/rdbms_common.py:1431
+msgid "create"
+msgstr "crear"
+
+#: ../roundup/backends/rdbms_common.py:1597
+msgid "unlink"
+msgstr "desenlazar"
+
+#: ../roundup/backends/rdbms_common.py:1601
+msgid "link"
+msgstr "enlazar"
+
+#: ../roundup/backends/rdbms_common.py:1720
+msgid "set"
+msgstr ""
+
+#: ../roundup/backends/rdbms_common.py:1744
+msgid "retired"
+msgstr "retirado"
+
+#: ../roundup/backends/rdbms_common.py:1774
+msgid "restored"
+msgstr "restaurado"
+
+#: ../roundup/cgi/actions.py:58
+#, python-format
+msgid "You do not have permission to %(action)s the %(classname)s class."
+msgstr "Ud. no posee los permisos necesarios para %(action)s la clase %(classname)s."
+
+#: ../roundup/cgi/actions.py:89
+msgid "No type specified"
+msgstr "No se especificó un tipo"
+
+#: ../roundup/cgi/actions.py:91
+msgid "No ID entered"
+msgstr "No se ingresó un ID"
+
+#: ../roundup/cgi/actions.py:97
+#, python-format
+msgid "\"%(input)s\" is not an ID (%(classname)s ID required)"
+msgstr "\"%(input)s\" no es un ID (se requieren IDs %(classname)s)"
+
+#: ../roundup/cgi/actions.py:117
+msgid "You may not retire the admin or anonymous user"
+msgstr "Ni el usuario admin ni el usuario anónimo pueden ser retirados"
+
+#: ../roundup/cgi/actions.py:124
+#, python-format
+msgid "%(classname)s %(itemid)s has been retired"
+msgstr "%(classname)s %(itemid)s ha sido retirado"
+
+# ../roundup/cgi/actions.py:163 :191
+#: ../roundup/cgi/actions.py:163
+#: ../roundup/cgi/actions.py:191
+msgid "You do not have permission to edit queries"
+msgstr "Ud. no posee los permisos necesarios para editar consultas"
+
+# ../roundup/cgi/actions.py:169 :197
+#: ../roundup/cgi/actions.py:169
+#: ../roundup/cgi/actions.py:197
+msgid "You do not have permission to store queries"
+msgstr "Ud. no posee los permisos necesarios para grabar consultas"
+
+#: ../roundup/cgi/actions.py:286
+#, python-format
+msgid "Not enough values on line %(line)s"
+msgstr "No hay valores suficientes en la línea %(line)s"
+
+#: ../roundup/cgi/actions.py:333
+msgid "Items edited OK"
+msgstr "Items editados exitosamente"
+
+#: ../roundup/cgi/actions.py:393
+#, python-format
+msgid "%(class)s %(id)s %(properties)s edited ok"
+msgstr "%(properties)s de %(class)s %(id)s editados exitosamente"
+
+#: ../roundup/cgi/actions.py:396
+#, python-format
+msgid "%(class)s %(id)s - nothing changed"
+msgstr "%(class)s %(id)s - sin modificaciones"
+
+#: ../roundup/cgi/actions.py:408
+#, python-format
+msgid "%(class)s %(id)s created"
+msgstr "%(class)s %(id)s creado"
+
+#: ../roundup/cgi/actions.py:440
+#, python-format
+msgid "You do not have permission to edit %(class)s"
+msgstr "Ud. no posee los permisos necesarios para editar %(class)s"
+
+#: ../roundup/cgi/actions.py:452
+#, python-format
+msgid "You do not have permission to create %(class)s"
+msgstr "Ud. no posee los permisos necesarios para crear %(class)s"
+
+#: ../roundup/cgi/actions.py:475
+msgid "You do not have permission to edit user roles"
+msgstr "Ud. no posee los permisos necesarios para editar roles de usuario"
+
+#: ../roundup/cgi/actions.py:519
+#, python-format
+msgid "Edit Error: someone else has edited this %s (%s). View <a target=\"new\" href=\"%s%s\">their changes</a> in a new window."
+msgstr "Error de edición: Alguien más ha editado este %s (%s). Vea los <a target=\"new\" href=\"%s%s\">cambios</a> que dicha persona ha realizado en una ventana aparte."
+
+#: ../roundup/cgi/actions.py:548
+#, python-format
+msgid "Edit Error: %s"
+msgstr "Error de edición: %s"
+
+# ../roundup/cgi/actions.py:579 :590 :761 :780
+#: ../roundup/cgi/actions.py:579
+#: ../roundup/cgi/actions.py:590
+#: ../roundup/cgi/actions.py:761
+#: ../roundup/cgi/actions.py:780
+#, python-format
+msgid "Error: %s"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:616
+msgid ""
+"Invalid One Time Key!\n"
+"(a Mozilla bug may cause this message to show up erroneously, please check your email)"
+msgstr ""
+"One Time Key inválida!\n"
+"(un bug de Mozilla puede ser el causante de que se visualice este mensaje en forma errónea, por favor verifique su casilla de e-mail)"
+
+#: ../roundup/cgi/actions.py:658
+#, python-format
+msgid "Password reset and email sent to %s"
+msgstr "Contraseña reinicializada y mensaje de e-mail enviado a %s"
+
+#: ../roundup/cgi/actions.py:667
+msgid "Unknown username"
+msgstr "Usuario desconocido"
+
+#: ../roundup/cgi/actions.py:675
+msgid "Unknown email address"
+msgstr "Dirección de e-mail desconocida"
+
+#: ../roundup/cgi/actions.py:680
+msgid "You need to specify a username or address"
+msgstr "Debe especificar un nombre de usuario o dirección de e-mail"
+
+#: ../roundup/cgi/actions.py:705
+#, python-format
+msgid "Email sent to %s"
+msgstr "Se ha enviado un mensaje de e-mail a %s"
+
+#: ../roundup/cgi/actions.py:724
+msgid "You are now registered, welcome!"
+msgstr "Ud. se ha registrado exitosamente, bienvenido!"
+
+#: ../roundup/cgi/actions.py:769
+msgid "It is not permitted to supply roles at registration."
+msgstr "No está permitido especificar roles en el momento del registro."
+
+#: ../roundup/cgi/actions.py:852
+msgid "You are logged out"
+msgstr "Ha salido del sistema exitosamente"
+
+#: ../roundup/cgi/actions.py:869
+msgid "Username required"
+msgstr "Se requiere el ingreso de un nombre de usuario"
+
+# ../roundup/cgi/actions.py:891 :895
+#: ../roundup/cgi/actions.py:897
+#: ../roundup/cgi/actions.py:901
+msgid "Invalid login"
+msgstr "nombre de usuario ó contraseña inválidos"
+
+#: ../roundup/cgi/actions.py:907
+msgid "You do not have permission to login"
+msgstr "Ud. no tiene permiso para ingresar al sistema"
+
+#: ../roundup/cgi/cgitb.py:49
+#, python-format
+msgid ""
+"<h1>Templating Error</h1>\n"
+"<p><b>%(exc_type)s</b>: %(exc_value)s</p>\n"
+"<p class=\"help\">Debugging information follows</p>"
+msgstr ""
+"<h1>Error de Templating</h1>\n"
+"<p><b>%(exc_type)s</b>: %(exc_value)s</p>\n"
+"<p class=\"help\">Información de depuración:</p>"
+
+#: ../roundup/cgi/cgitb.py:64
+#, python-format
+msgid "<li>\"%(name)s\" (%(info)s)</li>"
+msgstr ""
+
+#: ../roundup/cgi/cgitb.py:67
+#, python-format
+msgid "<li>Looking for \"%(name)s\", current path:<ol>%(path)s</ol></li>"
+msgstr "<li>Buscando \"%(name)s\", ruta actual:<ol>%(path)s</ol></li>"
+
+#: ../roundup/cgi/cgitb.py:71
+#, python-format
+msgid "<li>In %s</li>"
+msgstr "<li>En %s</li>"
+
+#: ../roundup/cgi/cgitb.py:76
+#, python-format
+msgid "A problem occurred in your template \"%s\"."
+msgstr "Ha ocurrido un problema en su template \"%s\"."
+
+#: ../roundup/cgi/cgitb.py:84
+#, python-format
+msgid ""
+"\n"
+"<li>While evaluating the %(info)r expression on line %(line)d\n"
+"<table class=\"otherinfo\" style=\"font-size: 90%%\">\n"
+" <tr><th colspan=\"2\" class=\"header\">Current variables:</th></tr>\n"
+" %(globals)s\n"
+" %(locals)s\n"
+"</table></li>\n"
+msgstr ""
+"\n"
+"<li>Cuando se evaluaba la expresión %(info)r en la línea %(line)d\n"
+"<table class=\"otherinfo\" style=\"font-size: 90%%\">\n"
+" <tr><th colspan=\"2\" class=\"header\">Variables activas:</th></tr>\n"
+" %(globals)s\n"
+" %(locals)s\n"
+"</table></li>\n"
+
+#: ../roundup/cgi/cgitb.py:103
+msgid "Full traceback:"
+msgstr "Traza completa"
+
+#: ../roundup/cgi/cgitb.py:116
+#, python-format
+msgid "<font size=+1><strong>%(exc_type)s</strong>: %(exc_value)s</font>"
+msgstr ""
+
+#: ../roundup/cgi/cgitb.py:120
+msgid "<p>A problem occurred while running a Python script. Here is the sequence of function calls leading up to the error, with the most recent (innermost) call first. The exception attributes are:"
+msgstr "<p>Ha ocurrido un problema ejecutando un script Python. Esta es la secuencia de llamadas a funciones que llevaron al error, con la llamada mas reciente (la mas anidada) ubicada primera. Los atributos de la excepción son:"
+
+#: ../roundup/cgi/cgitb.py:129
+msgid "&lt;file is None - probably inside <tt>eval</tt> or <tt>exec</tt>&gt;"
+msgstr "&lt;file es None - probablemente dentro de <tt>eval</tt> or <tt>exec</tt>&gt;"
+
+#: ../roundup/cgi/cgitb.py:138
+#, python-format
+msgid "in <strong>%s</strong>"
+msgstr "en <strong>%s</strong>"
+
+# ../roundup/cgi/cgitb.py:172 :178
+#: ../roundup/cgi/cgitb.py:172
+#: ../roundup/cgi/cgitb.py:178
+msgid "<em>undefined</em>"
+msgstr "<em>indefinido/a</em>"
+
+#: ../roundup/cgi/client.py:297
+msgid "Form Error: "
+msgstr "Error de formulario"
+
+#: ../roundup/cgi/client.py:350
+#, python-format
+msgid "Unrecognized charset: %r"
+msgstr "Conjunto de caracteres desconocido: %r"
+
+#: ../roundup/cgi/client.py:453
+msgid "Anonymous users are not allowed to use the web interface"
+msgstr "Los usuarios anonimos no tienen permitido usar esta interfaz Web"
+
+#: ../roundup/cgi/client.py:604
+msgid "You are not allowed to view this file."
+msgstr "Ud. no tiene permitido ver este archivo"
+
+#: ../roundup/cgi/client.py:696
+#, python-format
+msgid "%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n"
+msgstr "%(starttag)sTiempo transcurrido: %(seconds)fs%(endtag)s\n"
+
+#: ../roundup/cgi/client.py:700
+#, python-format
+msgid "%(starttag)sCache hits: %(cache_hits)d, misses %(cache_misses)d. Loading items: %(get_items)f secs. Filtering: %(filtering)f secs.%(endtag)s\n"
+msgstr "%(starttag)sAciertos Cache: %(cache_hits)d, no aciertos %(cache_misses)d. Cargando items: %(get_items)f secs. Filtrado: %(filtering)f secs.%(endtag)s\n"
+
+#: ../roundup/cgi/form_parser.py:283
+#, python-format
+msgid "link \"%(key)s\" value \"%(value)s\" not a designator"
+msgstr "el enlace \"%(key)s\" valor \"%(value)s\" no es un designador"
+
+#: ../roundup/cgi/form_parser.py:290
+#, python-format
+msgid "%(class)s %(property)s is not a link or multilink property"
+msgstr "%(property)s de %(class)s no es una propiedad enlace o multilink"
+
+#: ../roundup/cgi/form_parser.py:312
+#, python-format
+msgid "You have submitted a %(action)s action for the property \"%(property)s\" which doesn't exist"
+msgstr "Ha ingresado una acción %(action)s para la propiedad \"%(property)s\" que no existe"
+
+# ../roundup/cgi/form_parser.py:331 :357
+#: ../roundup/cgi/form_parser.py:331
+#: ../roundup/cgi/form_parser.py:357
+#, python-format
+msgid "You have submitted more than one value for the %s property"
+msgstr "Ha ingresado más de un valor para la propiedad %s"
+
+# ../roundup/cgi/form_parser.py:354 :360
+#: ../roundup/cgi/form_parser.py:354
+#: ../roundup/cgi/form_parser.py:360
+msgid "Password and confirmation text do not match"
+msgstr "La contraseña y el texto de confirmación no coinciden"
+
+#: ../roundup/cgi/form_parser.py:395
+#, python-format
+msgid "property \"%(propname)s\": \"%(value)s\" not currently in list"
+msgstr "propiedad \"%(propname)s\": \"%(value)s\" no se encuentra en este momento en la lista"
+
+#: ../roundup/cgi/form_parser.py:509
+#, python-format
+msgid "Required %(class)s property %(property)s not supplied"
+msgid_plural "Required %(class)s properties %(property)s not supplied"
+msgstr[0] "La propiedad %(property)s de la clase %(class)s es obligatoria y no se ha provisto"
+msgstr[1] "Las propiedades %(property)s de la clase %(class)s son obligatorias y no se han provisto"
+
+#: ../roundup/cgi/form_parser.py:532
+msgid "File is empty"
+msgstr "El archivo está vacío"
+
+#: ../roundup/cgi/templating.py:69
+#, python-format
+msgid "You are not allowed to %(action)s items of class %(class)s"
+msgstr "Ud. no tiene permitido %(action)s items de la clase %(class)s"
+
+#: ../roundup/cgi/templating.py:623
+msgid "(list)"
+msgstr "(lista)"
+
+#: ../roundup/cgi/templating.py:687
+msgid "Submit New Entry"
+msgstr "Crear nuevo elemento"
+
+# ../roundup/cgi/templating.py:673 :792 :1166 :1187 :1231 :1253 :1287 :1326
+# :1377 :1394 :1470 :1490 :1503 :1520 :1530 :1580 :1755
+#: ../roundup/cgi/templating.py:701
+#: ../roundup/cgi/templating.py:820
+#: ../roundup/cgi/templating.py:1194
+#: ../roundup/cgi/templating.py:1215
+#: ../roundup/cgi/templating.py:1259
+#: ../roundup/cgi/templating.py:1281
+#: ../roundup/cgi/templating.py:1315
+#: ../roundup/cgi/templating.py:1354
+#: ../roundup/cgi/templating.py:1405
+#: ../roundup/cgi/templating.py:1422
+#: ../roundup/cgi/templating.py:1498
+#: ../roundup/cgi/templating.py:1518
+#: ../roundup/cgi/templating.py:1531
+#: ../roundup/cgi/templating.py:1563
+#: ../roundup/cgi/templating.py:1573
+#: ../roundup/cgi/templating.py:1623
+#: ../roundup/cgi/templating.py:1810
+msgid "[hidden]"
+msgstr "[oculto]"
+
+#: ../roundup/cgi/templating.py:702
+msgid "New node - no history"
+msgstr "Nuevo nodo - sin historia"
+
+#: ../roundup/cgi/templating.py:802
+msgid "Submit Changes"
+msgstr "Enviar modificaciones"
+
+#: ../roundup/cgi/templating.py:884
+msgid "<em>The indicated property no longer exists</em>"
+msgstr "<em>La propiedad indicada ya no existe</em>"
+
+#: ../roundup/cgi/templating.py:885
+#, python-format
+msgid "<em>%s: %s</em>\n"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:898
+#, python-format
+msgid "The linked class %(classname)s no longer exists"
+msgstr "La clase relacionada %(classname)s ya no existe"
+
+# ../roundup/cgi/templating.py:903 :924
+#: ../roundup/cgi/templating.py:931
+#: ../roundup/cgi/templating.py:952
+msgid "<strike>The linked node no longer exists</strike>"
+msgstr "<strike>El nodo relacionado ya no existe</strike>"
+
+#: ../roundup/cgi/templating.py:994
+#: ../roundup/cgi/templating.py:1358
+#: ../roundup/cgi/templating.py:1379
+#: ../roundup/cgi/templating.py:1385
+msgid "No"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:994
+#: ../roundup/cgi/templating.py:1358
+#: ../roundup/cgi/templating.py:1377
+#: ../roundup/cgi/templating.py:1382
+msgid "Yes"
+msgstr "Si"
+
+#: ../roundup/cgi/templating.py:1005
+#, python-format
+msgid "%s: (no value)"
+msgstr "%s: (sin valor)"
+
+#: ../roundup/cgi/templating.py:1017
+msgid "<strong><em>This event is not handled by the history display!</em></strong>"
+msgstr "<strong><em>Este evento no es soportado por la visualización de historia!</em></strong>"
+
+#: ../roundup/cgi/templating.py:1029
+msgid "<tr><td colspan=4><strong>Note:</strong></td></tr>"
+msgstr "<tr><td colspan=4><strong>Nota:</strong></td></tr>"
+
+#: ../roundup/cgi/templating.py:1038
+msgid "History"
+msgstr "Historia"
+
+#: ../roundup/cgi/templating.py:1040
+msgid "<th>Date</th>"
+msgstr "<th>Fecha</th>"
+
+#: ../roundup/cgi/templating.py:1041
+msgid "<th>User</th>"
+msgstr "<th>Usuario</th>"
+
+#: ../roundup/cgi/templating.py:1042
+msgid "<th>Action</th>"
+msgstr "<th>Acción</th>"
+
+#: ../roundup/cgi/templating.py:1043
+msgid "<th>Args</th>"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1285
+msgid "*encrypted*"
+msgstr "*cifrado*"
+
+#: ../roundup/cgi/templating.py:1466
+msgid "default value for DateHTMLProperty must be either DateHTMLProperty or string date representation."
+msgstr "el valor por defecto para DateHTMLProperty debe ser un DateHTMLProperty o una cadena que represente una fecha."
+
+#: ../roundup/cgi/templating.py:1614
+#, python-format
+msgid "Attempt to look up %(attr)s on a missing value"
+msgstr "Se intentó buscar %(attr)s en un valor faltante"
+
+#: ../roundup/cgi/templating.py:1688
+#, python-format
+msgid "<option %svalue=\"-1\">- no selection -</option>"
+msgstr "<option %svalue=\"-1\">- sin selección -</option>"
+
+#: ../roundup/date.py:180
+msgid "Not a date spec: \"yyyy-mm-dd\", \"mm-dd\", \"HH:MM\", \"HH:MM:SS\" or \"yyyy-mm-dd.HH:MM:SS.SSS\""
+msgstr "No es una especificación de fecha: \"aaaa-mm-dd\", \"mm-dd\", \"HH:MM\", \"HH:MM:SS\" o \"aaaa-mm-dd.HH:MM:SS.SSS\""
+
+#: ../roundup/date.py:234
+#, python-format
+msgid "%r not a date / time spec \"yyyy-mm-dd\", \"mm-dd\", \"HH:MM\", \"HH:MM:SS\" or \"yyyy-mm-dd.HH:MM:SS.SSS\""
+msgstr "%r no es una especificación de fecha / hora \"aaaa-mm-dd\", \"mm-dd\", \"HH:MM\", \"HH:MM:SS\" o \"aaaa-mm-dd.HH:MM:SS.SSS\""
+
+#: ../roundup/date.py:532
+msgid "Not an interval spec: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS] [date spec]"
+msgstr "No es una especificación de intervalo de tiempo: [+-] [#a] [#m] [#s] [#d] [[[H]H:MM]:SS] [especific. fecha]"
+
+#: ../roundup/date.py:551
+msgid "Not an interval spec: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS]"
+msgstr "No es una especificación de intervalo de tiempo: [+-] [#a] [#m] [#s] [#d] [[[H]H:MM]:SS]"
+
+#: ../roundup/date.py:688
+#, python-format
+msgid "%(number)s year"
+msgid_plural "%(number)s years"
+msgstr[0] "%(number)s año"
+msgstr[1] "%(number)s años"
+
+#: ../roundup/date.py:692
+#, python-format
+msgid "%(number)s month"
+msgid_plural "%(number)s months"
+msgstr[0] "%(number)s mes"
+msgstr[1] "%(number)s meses"
+
+#: ../roundup/date.py:696
+#, python-format
+msgid "%(number)s week"
+msgid_plural "%(number)s weeks"
+msgstr[0] "%(number)s semana"
+msgstr[1] "%(number)s semanas"
+
+#: ../roundup/date.py:700
+#, python-format
+msgid "%(number)s day"
+msgid_plural "%(number)s days"
+msgstr[0] "%(number)s día"
+msgstr[1] "%(number)s días"
+
+#: ../roundup/date.py:704
+msgid "tomorrow"
+msgstr "mañana"
+
+#: ../roundup/date.py:706
+msgid "yesterday"
+msgstr "ayer"
+
+#: ../roundup/date.py:709
+#, python-format
+msgid "%(number)s hour"
+msgid_plural "%(number)s hours"
+msgstr[0] "%(number)s hora"
+msgstr[1] "%(number)s horas"
+
+#: ../roundup/date.py:713
+msgid "an hour"
+msgstr "una hora"
+
+#: ../roundup/date.py:715
+msgid "1 1/2 hours"
+msgstr "1 hora y 1/2"
+
+#: ../roundup/date.py:717
+#, python-format
+msgid "1 %(number)s/4 hours"
+msgid_plural "1 %(number)s/4 hours"
+msgstr[0] "1 %(number)s/4 de hora"
+msgstr[1] "1 %(number)s/4 de hora"
+
+#: ../roundup/date.py:721
+msgid "in a moment"
+msgstr "en un momento"
+
+#: ../roundup/date.py:723
+msgid "just now"
+msgstr "ahora"
+
+#: ../roundup/date.py:726
+msgid "1 minute"
+msgstr "1 minuto"
+
+#: ../roundup/date.py:729
+#, python-format
+msgid "%(number)s minute"
+msgid_plural "%(number)s minutes"
+msgstr[0] "%(number)s minuto"
+msgstr[1] "%(number)s minutos"
+
+#: ../roundup/date.py:732
+msgid "1/2 an hour"
+msgstr "media hora"
+
+#: ../roundup/date.py:734
+#, python-format
+msgid "%(number)s/4 hour"
+msgid_plural "%(number)s/4 hours"
+msgstr[0] "%(number)s/4 de hora"
+msgstr[1] "%(number)s/4s de hora"
+
+#: ../roundup/date.py:738
+#, python-format
+msgid "%s ago"
+msgstr "hace %s"
+
+#: ../roundup/date.py:740
+#, python-format
+msgid "in %s"
+msgstr "en %s"
+
+#: ../roundup/init.py:132
+#, python-format
+msgid ""
+"WARNING: directory '%s'\n"
+"\tcontains old-style template - ignored"
+msgstr ""
+"ATENCIÓN: El directorio '%s'\n"
+"\tcontiene una plantilla con el viejo formato - se ignorará"
+
+#: ../roundup/roundupdb.py:141
+msgid "files"
+msgstr "archivos"
+
+#: ../roundup/roundupdb.py:141
+msgid "messages"
+msgstr "mensajes"
+
+#: ../roundup/roundupdb.py:141
+msgid "nosy"
+msgstr "interesados"
+
+#: ../roundup/roundupdb.py:141
+msgid "superseder"
+msgstr "reemplazado por"
+
+#: ../roundup/roundupdb.py:141
+msgid "title"
+msgstr "título"
+
+#: ../roundup/roundupdb.py:142
+msgid "assignedto"
+msgstr "asignadoa"
+
+#: ../roundup/roundupdb.py:142
+msgid "priority"
+msgstr "prioridad"
+
+#: ../roundup/roundupdb.py:142
+msgid "status"
+msgstr "estado"
+
+#: ../roundup/roundupdb.py:142
+msgid "topic"
+msgstr "palabraclave"
+
+#: ../roundup/roundupdb.py:145
+msgid "activity"
+msgstr "actividad"
+
+#. following properties are common for all hyperdb classes
+#. they are listed here to keep things in one place
+#: ../roundup/roundupdb.py:145
+msgid "actor"
+msgstr "últimoactor"
+
+#: ../roundup/roundupdb.py:145
+msgid "creation"
+msgstr "creación"
+
+#: ../roundup/roundupdb.py:145
+msgid "creator"
+msgstr "creador"
+
+#: ../roundup/scripts/roundup_demo.py:32
+#, python-format
+msgid "Enter directory path to create demo tracker [%s]: "
+msgstr "Ingrese la ruta al directorio en el qeu se creará el tracker demo [%s]: "
+
+#: ../roundup/scripts/roundup_gettext.py:22
+#, python-format
+msgid "Usage: %(program)s <tracker home>"
+msgstr "Uso: %(program)s <directorio base de tracker>"
+
+#: ../roundup/scripts/roundup_gettext.py:37
+#, python-format
+msgid "No tracker templates found in directory %s"
+msgstr "No se encontraron templates de trackers en el directorio %s"
+
+#: ../roundup/scripts/roundup_mailgw.py:36
+#, python-format
+msgid ""
+"Usage: %(program)s [-v] [-c] [[-C class] -S field=value]* <instance home> [method]\n"
+"\n"
+"Options:\n"
+" -v: print version and exit\n"
+" -c: default class of item to create (else the tracker's MAIL_DEFAULT_CLASS)\n"
+" -C / -S: see below\n"
+"\n"
+"The roundup mail gateway may be called in one of four ways:\n"
+" . with an instance home as the only argument,\n"
+" . with both an instance home and a mail spool file,\n"
+" . with both an instance home and a POP/APOP server account, or\n"
+" . with both an instance home and a IMAP/IMAPS server account.\n"
+"\n"
+"It also supports optional -C and -S arguments that allows you to set a\n"
+"fields for a class created by the roundup-mailgw. The default class if\n"
+"not specified is msg, but the other classes: issue, file, user can\n"
+"also be used. The -S or --set options uses the same\n"
+"property=value[;property=value] notation accepted by the command line\n"
+"roundup command or the commands that can be given on the Subject line\n"
+"of an email message.\n"
+"\n"
+"It can let you set the type of the message on a per email address basis.\n"
+"\n"
+"PIPE:\n"
+" In the first case, the mail gateway reads a single message from the\n"
+" standard input and submits the message to the roundup.mailgw module.\n"
+"\n"
+"UNIX mailbox:\n"
+" In the second case, the gateway reads all messages from the mail spool\n"
+" file and submits each in turn to the roundup.mailgw module. The file is\n"
+" emptied once all messages have been successfully handled. The file is\n"
+" specified as:\n"
+"   mailbox /path/to/mailbox\n"
+"\n"
+"POP:\n"
+" In the third case, the gateway reads all messages from the POP server\n"
+" specified and submits each in turn to the roundup.mailgw module. The\n"
+" server is specified as:\n"
+"    pop username:password at server\n"
+" The username and password may be omitted:\n"
+"    pop username at server\n"
+"    pop server\n"
+" are both valid. The username and/or password will be prompted for if\n"
+" not supplied on the command-line.\n"
+"\n"
+"APOP:\n"
+" Same as POP, but using Authenticated POP:\n"
+"    apop username:password at server\n"
+"\n"
+"IMAP:\n"
+" Connect to an IMAP server. This supports the same notation as that of\n"
+" POP mail.\n"
+"    imap username:password at server\n"
+" It also allows you to specify a specific mailbox other than INBOX using\n"
+" this format:\n"
+"    imap username:password at server mailbox\n"
+"\n"
+"IMAPS:\n"
+" Connect to an IMAP server over ssl.\n"
+" This supports the same notation as IMAP.\n"
+"    imaps username:password at server [mailbox]\n"
+"\n"
+msgstr ""
+"Uso: %(program)s [-v] [-c] [[-C clase] -S campo=valor]* <directorio base instancia> [método]\n"
+"\n"
+"Opciones:\n"
+" -v: imprime version y sale\n"
+" -c: clase por omisión del item a crear (sinó se usará MAIL_DEFAULT_CLASS del tracker)\n"
+" -C / -S: ver mas abajo\n"
+"\n"
+"La pasarela de correo de roundup puede ser invocada en una de cuatro formas:\n"
+" . con un directorio base de instancia como único argumento,\n"
+" . con un directorio base de instancia y un archivo de spool de correo,\n"
+" . con un directorio base de instancia y una cuenta de un servidor POP/APOP, o\n"
+" . con un directorio base de instancia y una cuenta de un servidor IMAP/IMAPS.\n"
+"\n"
+"También soporta los argumentos opcionales -C y -S que le permiten establecer\n"
+"campos para una clase creada por la pasarela de correo de Roundup roundup-mailgw.\n"
+"La clase por omisión es msg, pero las otras clases: issue, file, user tambien\n"
+"pueden usarse. Las opciones -S y --set usan la notación\n"
+"propiedad=valor[;propiedad=valor] aceptada por el comando roundup de línea de\n"
+"comandos o los comandos que pueden ser pasados en el campo Asunto: de un mensaje\n"
+"de correo electrónico.\n"
+"\n"
+"También le permite establecer el tipo de mensaje basado en la dirección de correo usada.\n"
+"\n"
+"PIPE:\n"
+" En el primer caso, la pasarela de correo lee un mensaje desde la entrada estándar\n"
+" y lo envía al módulo roundup.mailgw.\n"
+"\n"
+"UNIX mailbox:\n"
+" En el segundo caso, la pasarela lee todos los mensajes desde el archivo de\n"
+" spool de correo y envía los mismos de a uno al módulo roundup.mailgw. El archivo\n"
+" se vacía una vez que todos los mensajes han sido procesados exitosamente. El\n"
+" archivo se especifica como:\n"
+"   mailbox /ruta/al/mailbox\n"
+"\n"
+"POP:\n"
+" En el tercer caso, la pasarela lee todos los mensajes en el servidor\n"
+" POP y envía los mismos de a uno al módulo roundup.mailgw. El servidor\n"
+" POP se especifica como:\n"
+"    pop nombreusuario:contraseña at servidor\n"
+" El nombreusuario y la contraseña pueden omitirse:\n"
+"    pop nombreusuario at servidor\n"
+"    pop servidor\n"
+" son válidos. El nombre de usuario y/o la contraseña se solicitarán si no\n"
+" se proveen en la línea de comandos.\n"
+"\n"
+"APOP:\n"
+" Idéntico a POP, pero usando Authenticated POP:\n"
+"    apop nombreusuario:contraseña at servidor\n"
+"\n"
+"IMAP:\n"
+" Se conecta a un servidor IMAP. Esta forma soporta la misma notación que correo POP\n"
+"    imap nombreusuario:contraseña at servidor\n"
+" También le permite especificar una casilla distinta a INBOX usando\n"
+" el siguiente formato:\n"
+"    imap nombreusuario:contraseña at servidor casilla\n"
+"\n"
+"IMAPS:\n"
+" Se conecta a un servidor IMAP usando ssl.\n"
+" Esta forma soporta la misma notación que IMAP.\n"
+"    imaps nombreusuario:contraseña at servidor [casilla]\n"
+"\n"
+
+#: ../roundup/scripts/roundup_mailgw.py:147
+msgid "Error: not enough source specification information"
+msgstr "Error: no hay información de especificación de origen suficiente"
+
+#: ../roundup/scripts/roundup_mailgw.py:157
+msgid "Error: pop specification not valid"
+msgstr "Error: especification pop no válida"
+
+#: ../roundup/scripts/roundup_mailgw.py:164
+msgid "Error: apop specification not valid"
+msgstr "Error: especification apop no válida"
+
+#: ../roundup/scripts/roundup_mailgw.py:178
+msgid "Error: The source must be either \"mailbox\", \"pop\", \"apop\", \"imap\" or \"imaps\""
+msgstr "Error: EL origen debe ser \"mailbox\", \"pop\", \"apop\", \"imap\" o \"imaps\""
+
+#: ../roundup/scripts/roundup_server.py:156
+msgid ""
+"<html><head><title>Roundup trackers index</title></head>\n"
+"<body><h1>Roundup trackers index</h1><ol>\n"
+msgstr ""
+"<html><head><title>Índice de trackers Roundup</title></head>\n"
+"<body><h1>Índice de trackers Roundup</h1><ol>\n"
+
+#: ../roundup/scripts/roundup_server.py:257
+#, python-format
+msgid "Error: %s: %s"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:267
+msgid "WARNING: ignoring \"-g\" argument, not root"
+msgstr "ATENCIÓN: ignorando argumento \"-g\" , Ud. no es root"
+
+#: ../roundup/scripts/roundup_server.py:273
+msgid "Can't change groups - no grp module"
+msgstr "No puede cambiar grupos - el módulo grp no está presente"
+
+#: ../roundup/scripts/roundup_server.py:282
+#, python-format
+msgid "Group %(group)s doesn't exist"
+msgstr "El grupo %(group)s no existe"
+
+#: ../roundup/scripts/roundup_server.py:293
+msgid "Can't run as root!"
+msgstr "No puede ejecutarse como root!"
+
+#: ../roundup/scripts/roundup_server.py:296
+msgid "WARNING: ignoring \"-u\" argument, not root"
+msgstr "ATENCIÓN: ignorando argumento \"-u\", Ud. no es root"
+
+#: ../roundup/scripts/roundup_server.py:301
+msgid "Can't change users - no pwd module"
+msgstr "No puedo cambiar usuarios - no existe el módulo pwd"
+
+#: ../roundup/scripts/roundup_server.py:310
+#, python-format
+msgid "User %(user)s doesn't exist"
+msgstr "El usuario %(user)s no existe"
+
+#: ../roundup/scripts/roundup_server.py:437
+#, python-format
+msgid "Multiprocess mode \"%s\" is not available, switching to single-process"
+msgstr "El modo multiproceso \"%s\" no está disponible, conmutado a proceso simple"
+
+#: ../roundup/scripts/roundup_server.py:460
+#, python-format
+msgid "Unable to bind to port %s, port already in use."
+msgstr "Imposible asociarse al puerto %s, el mismo ya está en uso."
+
+#: ../roundup/scripts/roundup_server.py:528
+msgid ""
+" -c <Command>  Windows Service options.\n"
+"               If you want to run the server as a Windows Service, you\n"
+"               must use configuration file to specify tracker homes.\n"
+"               Logfile option is required to run Roundup Tracker service.\n"
+"               Typing \"roundup-server -c help\" shows Windows Services\n"
+"               specifics."
+msgstr ""
+" -c <Comando>  Opciones de Servicio Windows.\n"
+"               Si desdea ejecutar el servidor como un Servicio Windows, debe usar\n"
+"               un archivo de configuración para especificar los directorios base\n"
+"               de los trackers.\n"
+"               Cuando ejecuta el Roundup Tracker como un servicio deb usar la\n"
+"               opción para activar un archivo de registro.\n"
+"               Tipee \"roundup-server -c help\" para ver ayuda específica para\n"
+"               Servicios Web."
+
+#: ../roundup/scripts/roundup_server.py:535
+msgid ""
+" -u <UID>      runs the Roundup web server as this UID\n"
+" -g <GID>      runs the Roundup web server as this GID\n"
+" -d <PIDfile>  run the server in the background and write the server's PID\n"
+"               to the file indicated by PIDfile. The -l option *must* be\n"
+"               specified if -d is used."
+msgstr ""
+" -u <UID>      ejecuta el servidor web de Roundup como este UID\n"
+" -g <GID>      ejecuta el servidor web de Roundup como este GID\n"
+" -d <PIDfile>  ejecuta el servidor web de Roundup en segundo plano y escribe el\n"
+"               PID del servidor en el archivo especificado por PIDfile.\n"
+"               La opción -l *debe* ser especificada si se usa la opción -d."
+
+#: ../roundup/scripts/roundup_server.py:542
+#, python-format
+msgid ""
+"%(message)sUsage: roundup-server [options] [name=tracker home]*\n"
+"\n"
+"Options:\n"
+" -v            print the Roundup version number and exit\n"
+" -h            print this text and exit\n"
+" -S            create or update configuration file and exit\n"
+" -C <fname>    use configuration file <fname>\n"
+" -n <name>     set the host name of the Roundup web server instance\n"
+" -p <port>     set the port to listen on (default: %(port)s)\n"
+" -l <fname>    log to the file indicated by fname instead of stderr/stdout\n"
+" -N            log client machine names instead of IP addresses (much slower)\n"
+" -t <mode>     multiprocess mode (default: %(mp_def)s).\n"
+"               Allowed values: %(mp_types)s.\n"
+"%(os_part)s\n"
+"\n"
+"Long options:\n"
+" --version          print the Roundup version number and exit\n"
+" --help             print this text and exit\n"
+" --save-config      create or update configuration file and exit\n"
+" --config <fname>   use configuration file <fname>\n"
+" All settings of the [main] section of the configuration file\n"
+" also may be specified in form --<name>=<value>\n"
+"\n"
+"Examples:\n"
+"\n"
+" roundup-server -S -C /opt/roundup/etc/roundup-server.ini \\\n"
+"    -n localhost -p 8917 -l /var/log/roundup.log \\\n"
+"    support=/var/spool/roundup-trackers/support\n"
+"\n"
+" roundup-server -C /opt/roundup/etc/roundup-server.ini\n"
+"\n"
+" roundup-server support=/var/spool/roundup-trackers/support\n"
+"\n"
+" roundup-server -d /var/run/roundup.pid -l /var/log/roundup.log \\\n"
+"    support=/var/spool/roundup-trackers/support\n"
+"\n"
+"Configuration file format:\n"
+"   Roundup Server configuration file has common .ini file format.\n"
+"   Configuration file created with 'roundup-server -S' contains\n"
+"   detailed explanations for each option.  Please see that file\n"
+"   for option descriptions.\n"
+"\n"
+"How to use \"name=tracker home\":\n"
+"   These arguments set the tracker home(s) to use. The name is how the\n"
+"   tracker is identified in the URL (it's the first part of the URL path).\n"
+"   The tracker home is the directory that was identified when you did\n"
+"   \"roundup-admin init\". You may specify any number of these name=home\n"
+"   pairs on the command-line. Make sure the name part doesn't include\n"
+"   any url-unsafe characters like spaces, as these confuse IE.\n"
+msgstr ""
+"%(message)sUso: roundup-server [opciones] [nombre=directorio base de tracker]*\n"
+"\n"
+"Opciones:\n"
+" -v            imprime el número de versión de Roundup y sale\n"
+" -h            imprime este texto y sale\n"
+" -S            crea o actualiza el archivo de configuración y sale\n"
+" -C <fname>    usa el archivo de configuración <fname>\n"
+" -n <name>     especifica el nombre de host de la instancia del servidor web de Roundup\n"
+" -p <port>     especifica el puerto en el cual escuchará el servidor (por omisión: %(port)s)\n"
+" -l <fname>    almacena bitácora en el archivo indicado por fname en lugar de hacerlo a stderr/stdout\n"
+" -N            almacena en bitácora los nombres de los equipos clientes en lugar de direcciones IP (mucho mas lento)\n"
+" -t <mode>     mod multiproceso (por omisión: %(mp_def)s).\n"
+"               Valores permitidos: %(mp_types)s.\n"
+"%(os_part)s\n"
+"\n"
+"Opciones largas:\n"
+" --version          imprime el número de versión de Roundup y sale\n"
+" --help             imprime este texto y sale\n"
+" --save-config      crea o actualiza el archivo de configuración y sale\n"
+" --config <fname>   usa el archivo de configuración <fname>\n"
+" Todos las variables de la sección [main] del archivo de configuración\n"
+" pueden también especificarse usando la forma --<nombre>=<valor>\n"
+"\n"
+"Ejemplos:\n"
+"\n"
+" roundup-server -S -C /opt/roundup/etc/roundup-server.ini \\\n"
+"    -n localhost -p 8917 -l /var/log/roundup.log \\\n"
+"    support=/var/spool/roundup-trackers/support\n"
+"\n"
+" roundup-server -C /opt/roundup/etc/roundup-server.ini\n"
+"\n"
+" roundup-server support=/var/spool/roundup-trackers/support\n"
+"\n"
+" roundup-server -d /var/run/roundup.pid -l /var/log/roundup.log \\\n"
+"    support=/var/spool/roundup-trackers/support\n"
+"\n"
+"Formato de archivo de configuración:\n"
+"   El archivo de configuración del Servidor Roundup tiene un formato de archivo.ini común.\n"
+"   El archivo de configuración creado con 'roundup-server -S' contiene\n"
+"   explicaciones detalladas para cada opción. Por favor vea dicho archivo para encontrar\n"
+"   descripciones de las variables.\n"
+"\n"
+"Cómo usar \"nombre=directorio base de tracker\":\n"
+"   Estos argumentos especifican el directorio base a usarse para el tracker.\n"
+"   El nombre es cómo se identificará el tracker en la URL (será la primera parte\n"
+"   en la ruta del la URL).\n"
+"   El directorio base de tracker es el directorio que se identificó cuando se\n"
+"   ejecutó \"roundup-admin init\". Pueden especificarse un número arbirario de dichos\n"
+"   pares nombre=dirbase en la línea de comandos. Asegúrese de el nombre no contengan\n"
+"   caracteres tales como espacios, dado que los mismos confunden a Internet Explorer.\n"
+
+#: ../roundup/scripts/roundup_server.py:690
+msgid "Instances must be name=home"
+msgstr "Las Instancias debe ser de la forma nombre=directorio base"
+
+#: ../roundup/scripts/roundup_server.py:704
+#, python-format
+msgid "Configuration saved to %s"
+msgstr "Configuración guardada en %s"
+
+#: ../roundup/scripts/roundup_server.py:722
+msgid "Sorry, you can't run the server as a daemon on this Operating System"
+msgstr "Lo siento, no puede ejecutar el servidor como un demonio en este Sistema Operativo"
+
+#: ../roundup/scripts/roundup_server.py:734
+#, python-format
+msgid "Roundup server started on %(HOST)s:%(PORT)s"
+msgstr "servidor Roundup iniciado en %(HOST)s:%(PORT)s"
+
+#: ../templates/classic/html/_generic.collision.html:4
+#: ../templates/minimal/html/_generic.collision.html:4
+msgid "${class} Edit Collision - ${tracker}"
+msgstr "Colisión de edición ${class} - ${tracker}"
+
+#: ../templates/classic/html/_generic.collision.html:7
+#: ../templates/minimal/html/_generic.collision.html:7
+msgid "${class} Edit Collision"
+msgstr "Colisión de edición ${class}"
+
+#: ../templates/classic/html/_generic.collision.html:14
+#: ../templates/minimal/html/_generic.collision.html:14
+msgid ""
+"\n"
+"  There has been a collision. Another user updated this node\n"
+"  while you were editing. Please <a href='${context}'>reload</a>\n"
+"  the node and review your edits.\n"
+msgstr ""
+"\n"
+"  Se ha encontrado una colisión. Otro usuario ha actualizado este nodo\n"
+"  mientras Ud. lo editaba. Por favor <a href='${context}'>revisualice</a>\n"
+"  el nodo y revise sus modificaciones.\n"
+
+#: ../templates/classic/html/_generic.help.html:9
+#: ../templates/minimal/html/_generic.help.html:9
+msgid "${property} help - ${tracker}"
+msgstr "${property} ayuda - ${tracker}"
+
+#: ../templates/classic/html/_generic.help.html:31
+#: ../templates/minimal/html/_generic.help.html:31
+msgid " Cancel "
+msgstr " Cancelar "
+
+#: ../templates/classic/html/_generic.help.html:34
+#: ../templates/minimal/html/_generic.help.html:34
+msgid " Apply "
+msgstr " Aplicar "
+
+#: ../templates/classic/html/_generic.help.html:41
+#: ../templates/classic/html/issue.index.html:67
+#: ../templates/minimal/html/_generic.help.html:41
+msgid "&lt;&lt; previous"
+msgstr "&lt;&lt; anterior"
+
+#: ../templates/classic/html/_generic.help.html:52
+#: ../templates/classic/html/issue.index.html:75
+#: ../templates/minimal/html/_generic.help.html:52
+msgid "${start}..${end} out of ${total}"
+msgstr "${start}..${end} de un total de ${total}"
+
+#: ../templates/classic/html/_generic.help.html:56
+#: ../templates/classic/html/issue.index.html:78
+#: ../templates/minimal/html/_generic.help.html:56
+msgid "next &gt;&gt;"
+msgstr "próxima &gt;&gt;"
+
+#: ../templates/classic/html/_generic.index.html:6
+#: ../templates/classic/html/_generic.item.html:4
+#: ../templates/minimal/html/_generic.index.html:6
+#: ../templates/minimal/html/_generic.item.html:4
+msgid "${class} editing - ${tracker}"
+msgstr "Edición de ${class} - ${tracker}"
+
+#: ../templates/classic/html/_generic.index.html:9
+#: ../templates/classic/html/_generic.item.html:7
+#: ../templates/minimal/html/_generic.index.html:9
+#: ../templates/minimal/html/_generic.item.html:7
+msgid "${class} editing"
+msgstr "Edición de ${class}"
+
+#: ../templates/classic/html/_generic.index.html:14
+#: ../templates/classic/html/_generic.item.html:12
+#: ../templates/classic/html/file.item.html:9
+#: ../templates/classic/html/issue.index.html:10
+#: ../templates/classic/html/issue.item.html:28
+#: ../templates/classic/html/msg.item.html:26
+#: ../templates/classic/html/user.index.html:9
+#: ../templates/classic/html/user.item.html:28
+#: ../templates/minimal/html/_generic.index.html:14
+#: ../templates/minimal/html/_generic.item.html:12
+#: ../templates/minimal/html/user.index.html:9
+#: ../templates/minimal/html/user.item.html:18
+#: ../templates/minimal/html/user.register.html:14
+msgid "You are not allowed to view this page."
+msgstr "Ud. no posee los permisos necesarios para ver esta página."
+
+#: ../templates/classic/html/_generic.index.html:22
+#: ../templates/minimal/html/_generic.index.html:22
+msgid "<p class=\"form-help\"> You may edit the contents of the ${classname} class using this form. Commas, newlines and double quotes (\") must be handled delicately. You may include commas and newlines by enclosing the values in double-quotes (\"). Double quotes themselves must be quoted by doubling (\"\"). </p> <p class=\"form-help\"> Multilink properties have their multiple values colon (\":\") separated (... ,\"one:two:three\", ...) </p> <p class=\"form-help\"> Remove entries by deleting their line. Add new entries by appending them to the table - put an X in the id column. </p>"
+msgstr "<p class=\"form-help\"> Puede editar el contenido de la clase ${classname} usando este formulario. Las comas, los saltos de línea y las comillas dobles (\") deben ser tratadas con cuidado. Para incluir comas y saltos de línea encierre los valores entre comillas dobles (\"). si quiere ingresar comillas dobles debe usarlas en grupos de a dos (\"\"). </p> <p class=\"form-help\"> Para las propiedades Multilink ingrese sus múltiples valores separados por dos puntos (\":\") (... ,\"uno:dos:tres\", ...) </p> <p class=\"form-help\"> Para eliminar elementos elimine la línea correspondiente. Para agregar nuevos elementos anéxelos a la tabla y coloque una X en la columna id. </p>"
+
+#: ../templates/classic/html/_generic.index.html:44
+#: ../templates/minimal/html/_generic.index.html:44
+msgid "Edit Items"
+msgstr "Editar Items"
+
+#: ../templates/classic/html/file.index.html:4
+msgid "List of files - ${tracker}"
+msgstr "Lista de archivos - ${tracker}"
+
+#: ../templates/classic/html/file.index.html:5
+msgid "List of files"
+msgstr "Lista de archivos"
+
+#: ../templates/classic/html/file.index.html:10
+msgid "Download"
+msgstr "Descargar"
+
+#: ../templates/classic/html/file.index.html:11
+#: ../templates/classic/html/file.item.html:22
+msgid "Content Type"
+msgstr "Tipo de Contenido"
+
+#: ../templates/classic/html/file.index.html:12
+msgid "Uploaded By"
+msgstr "Subido por"
+
+#: ../templates/classic/html/file.index.html:13
+#: ../templates/classic/html/msg.item.html:43
+msgid "Date"
+msgstr "Fecha"
+
+#: ../templates/classic/html/file.item.html:2
+msgid "File display - ${tracker}"
+msgstr "Visualización de archivos - ${tracker}"
+
+#: ../templates/classic/html/file.item.html:4
+msgid "File display"
+msgstr "Visualización de archivos"
+
+#: ../templates/classic/html/file.item.html:18
+#: ../templates/classic/html/user.item.html:39
+#: ../templates/classic/html/user.register.html:17
+msgid "Name"
+msgstr "Nombre"
+
+#: ../templates/classic/html/file.item.html:40
+msgid "download"
+msgstr "descargar"
+
+#: ../templates/classic/html/home.classlist.html:2
+#: ../templates/minimal/html/home.classlist.html:2
+msgid "List of classes - ${tracker}"
+msgstr "Lista de clases - ${tracker}"
+
+#: ../templates/classic/html/home.classlist.html:4
+#: ../templates/minimal/html/home.classlist.html:4
+msgid "List of classes"
+msgstr "Lista de clases"
+
+#: ../templates/classic/html/issue.index.html:4
+msgid "List of issues - ${tracker}"
+msgstr "Lista de issues - ${tracker}"
+
+#: ../templates/classic/html/issue.index.html:6
+msgid "List of issues"
+msgstr "Lista de issues"
+
+#: ../templates/classic/html/issue.index.html:16
+#: ../templates/classic/html/issue.item.html:44
+msgid "Priority"
+msgstr "Prioridad"
+
+#: ../templates/classic/html/issue.index.html:17
+msgid "ID"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:18
+msgid "Creation"
+msgstr "Creación"
+
+#: ../templates/classic/html/issue.index.html:19
+msgid "Activity"
+msgstr "Actividad"
+
+#: ../templates/classic/html/issue.index.html:20
+msgid "Actor"
+msgstr "ültimo actor"
+
+#: ../templates/classic/html/issue.index.html:21
+msgid "Topic"
+msgstr "Palabra clave"
+
+#: ../templates/classic/html/issue.index.html:22
+#: ../templates/classic/html/issue.item.html:39
+msgid "Title"
+msgstr "Título"
+
+#: ../templates/classic/html/issue.index.html:23
+#: ../templates/classic/html/issue.item.html:46
+msgid "Status"
+msgstr "Estado"
+
+#: ../templates/classic/html/issue.index.html:24
+msgid "Creator"
+msgstr "Creador"
+
+#: ../templates/classic/html/issue.index.html:25
+msgid "Assigned&nbsp;To"
+msgstr "Asignado&nbsp;a"
+
+#: ../templates/classic/html/issue.index.html:91
+msgid "Download as CSV"
+msgstr "Descargar como CSV"
+
+#: ../templates/classic/html/issue.index.html:99
+msgid "Sort on:"
+msgstr "Ordenar por:"
+
+#: ../templates/classic/html/issue.index.html:102
+#: ../templates/classic/html/issue.index.html:119
+msgid "- nothing -"
+msgstr "- nada -"
+
+#: ../templates/classic/html/issue.index.html:110
+#: ../templates/classic/html/issue.index.html:127
+msgid "Descending:"
+msgstr "Descendente:"
+
+#: ../templates/classic/html/issue.index.html:116
+msgid "Group on:"
+msgstr "Agrupar por:"
+
+#: ../templates/classic/html/issue.index.html:133
+msgid "Redisplay"
+msgstr "Revisualizar"
+
+#: ../templates/classic/html/issue.item.html:7
+msgid "Issue ${id}: ${title} - ${tracker}"
+msgstr "Issue ${id}: ${title} - ${tracker}"
+
+#: ../templates/classic/html/issue.item.html:10
+#, fuzzy
+msgid "New Issue - ${tracker}"
+msgstr "Issue nuevo - ${tracker}"
+
+#: ../templates/classic/html/issue.item.html:14
+#, fuzzy
+msgid "New Issue"
+msgstr "Issue nuevo"
+
+#: ../templates/classic/html/issue.item.html:16
+msgid "New Issue Editing"
+msgstr "Edición de Nuevo Issue"
+
+#: ../templates/classic/html/issue.item.html:19
+msgid "Issue${id}"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:22
+msgid "Issue${id} Editing"
+msgstr "Edición de Issue${id}"
+
+#: ../templates/classic/html/issue.item.html:51
+msgid "Superseder"
+msgstr "Reemplazado por"
+
+#: ../templates/classic/html/issue.item.html:56
+msgid "View: ${link}"
+msgstr "Ver: ${link}"
+
+#: ../templates/classic/html/issue.item.html:60
+msgid "Nosy List"
+msgstr "Lista de interesados"
+
+#: ../templates/classic/html/issue.item.html:69
+msgid "Assigned To"
+msgstr "Asignado a"
+
+#: ../templates/classic/html/issue.item.html:71
+msgid "Topics"
+msgstr "Palabras clave"
+
+#: ../templates/classic/html/issue.item.html:79
+msgid "Change Note"
+msgstr "Nota de modificación"
+
+#: ../templates/classic/html/issue.item.html:87
+msgid "File"
+msgstr "Archivo"
+
+#: ../templates/classic/html/issue.item.html:106
+msgid "<table class=\"form\"> <tr> <td>Note:&nbsp;</td> <th class=\"required\">highlighted</th> <td>&nbsp;fields are required.</td> </tr> </table>"
+msgstr "<table class=\"form\"> <tr> <td>Nota: Los campos&nbsp;</td> <th class=\"required\">resaltados</th> <td>&nbsp;son obligatorios.</td> </tr> </table>"
+
+#: ../templates/classic/html/issue.item.html:120
+msgid "Created on <b>${creation}</b> by <b>${creator}</b>, last changed <b>${activity}</b> by <b>${actor}</b>."
+msgstr "Creado el <b>${creation}</b> por <b>${creator}</b>, última modificación el <b>${activity}</b> por <b>${actor}</b>."
+
+#: ../templates/classic/html/issue.item.html:124
+#: ../templates/classic/html/msg.item.html:56
+msgid "Files"
+msgstr "Archivos"
+
+#: ../templates/classic/html/issue.item.html:126
+#: ../templates/classic/html/msg.item.html:58
+msgid "File name"
+msgstr "Nombre de archivo"
+
+#: ../templates/classic/html/issue.item.html:127
+#: ../templates/classic/html/msg.item.html:59
+msgid "Uploaded"
+msgstr "Subido"
+
+#: ../templates/classic/html/issue.item.html:128
+msgid "Type"
+msgstr "Tipo"
+
+#: ../templates/classic/html/issue.item.html:129
+#: ../templates/classic/html/query.edit.html:30
+msgid "Edit"
+msgstr "Editar"
+
+#: ../templates/classic/html/issue.item.html:130
+msgid "Remove"
+msgstr "Eliminar"
+
+#: ../templates/classic/html/issue.item.html:150
+#: ../templates/classic/html/issue.item.html:171
+#: ../templates/classic/html/query.edit.html:50
+msgid "remove"
+msgstr "eliminar"
+
+#: ../templates/classic/html/issue.item.html:157
+#: ../templates/classic/html/msg.index.html:9
+msgid "Messages"
+msgstr "Mensajes"
+
+#: ../templates/classic/html/issue.item.html:161
+msgid "msg${id} (view)"
+msgstr "mensaje${id} (ver)"
+
+#: ../templates/classic/html/issue.item.html:162
+msgid "Author: ${author}"
+msgstr "Autor: ${author}"
+
+#: ../templates/classic/html/issue.item.html:164
+msgid "Date: ${date}"
+msgstr "Fecha: ${date}"
+
+#: ../templates/classic/html/issue.search.html:2
+msgid "Issue searching - ${tracker}"
+msgstr "Búsqueda de Issues - ${tracker}"
+
+#: ../templates/classic/html/issue.search.html:4
+msgid "Issue searching"
+msgstr "Búsqueda de Issues"
+
+#: ../templates/classic/html/issue.search.html:25
+msgid "Filter on"
+msgstr "Filtrar por"
+
+#: ../templates/classic/html/issue.search.html:26
+msgid "Display"
+msgstr "Visualizar"
+
+#: ../templates/classic/html/issue.search.html:27
+msgid "Sort on"
+msgstr "Ordenar por"
+
+#: ../templates/classic/html/issue.search.html:28
+msgid "Group on"
+msgstr "Agrupar por"
+
+#: ../templates/classic/html/issue.search.html:32
+msgid "All text*:"
+msgstr "Todo el texto*:"
+
+#: ../templates/classic/html/issue.search.html:40
+msgid "Title:"
+msgstr "Título:"
+
+#: ../templates/classic/html/issue.search.html:50
+msgid "Topic:"
+msgstr "Palabra clave:"
+
+#: ../templates/classic/html/issue.search.html:58
+msgid "ID:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:66
+msgid "Creation Date:"
+msgstr "Fecha de creación:"
+
+#: ../templates/classic/html/issue.search.html:77
+msgid "Creator:"
+msgstr "Creador:"
+
+#: ../templates/classic/html/issue.search.html:79
+msgid "created by me"
+msgstr "creado por mí"
+
+#: ../templates/classic/html/issue.search.html:88
+msgid "Activity:"
+msgstr "Actividad:"
+
+#: ../templates/classic/html/issue.search.html:99
+msgid "Actor:"
+msgstr "Último actor:"
+
+#: ../templates/classic/html/issue.search.html:101
+msgid "done by me"
+msgstr "hecho por mí"
+
+#: ../templates/classic/html/issue.search.html:112
+msgid "Priority:"
+msgstr "Prioridad:"
+
+#: ../templates/classic/html/issue.search.html:114
+#: ../templates/classic/html/issue.search.html:130
+msgid "not selected"
+msgstr "no seleccionado"
+
+#: ../templates/classic/html/issue.search.html:125
+msgid "Status:"
+msgstr "Estado:"
+
+#: ../templates/classic/html/issue.search.html:128
+msgid "not resolved"
+msgstr "sin resolver"
+
+#: ../templates/classic/html/issue.search.html:143
+msgid "Assigned to:"
+msgstr "Asignado a:"
+
+#: ../templates/classic/html/issue.search.html:146
+msgid "assigned to me"
+msgstr "asignado a mí"
+
+#: ../templates/classic/html/issue.search.html:148
+msgid "unassigned"
+msgstr "no asignado"
+
+#: ../templates/classic/html/issue.search.html:158
+msgid "No Sort or group:"
+msgstr "No ordenar o agrupar"
+
+#: ../templates/classic/html/issue.search.html:166
+msgid "Pagesize:"
+msgstr "Tamaño de página"
+
+#: ../templates/classic/html/issue.search.html:172
+msgid "Start With:"
+msgstr "Comenzar con:"
+
+#: ../templates/classic/html/issue.search.html:178
+msgid "Sort Descending:"
+msgstr "Ordenar en forma descendente:"
+
+#: ../templates/classic/html/issue.search.html:185
+msgid "Group Descending:"
+msgstr "Agrupar en forma descendente:"
+
+#: ../templates/classic/html/issue.search.html:192
+msgid "Query name**:"
+msgstr "Nombre de la consulta**:"
+
+#: ../templates/classic/html/issue.search.html:202
+#: ../templates/classic/html/page.html:31
+#: ../templates/classic/html/page.html:60
+#: ../templates/minimal/html/page.html:31
+msgid "Search"
+msgstr "Buscar"
+
+#: ../templates/classic/html/issue.search.html:206
+msgid "*: The \"all text\" field will look in message bodies and issue titles<br> **: If you supply a name, the query will be saved off and available as a link in the sidebar"
+msgstr "*: El campo \"Todo el texto\" busca en los cuerpos de los mensajes y los títulos de los issues<br> **: Si Ud. provee un nombre, la consulta será grabada y estará disponible como un enlace en la barra lateral"
+
+#: ../templates/classic/html/keyword.item.html:3
+msgid "Keyword editing - ${tracker}"
+msgstr "Edición de Palabras clave - ${tracker}"
+
+#: ../templates/classic/html/keyword.item.html:5
+msgid "Keyword editing"
+msgstr "Edición de Palabras clave"
+
+#: ../templates/classic/html/keyword.item.html:11
+msgid "Existing Keywords"
+msgstr "Palabras clave existentes"
+
+#: ../templates/classic/html/keyword.item.html:20
+msgid "To edit an existing keyword (for spelling or typing errors), click on its entry above."
+msgstr "Para editar una Palabra clave existente (para corregir errores de ortografía o errores de tipeo), haga click en la misma arriba."
+
+#: ../templates/classic/html/keyword.item.html:27
+msgid "To create a new keyword, enter it below and click \"Submit New Entry\"."
+msgstr "Para crear una nueva Palabra clave, ingrese la misma abajo y haga click en \"Crear nuevo elemento\"."
+
+#: ../templates/classic/html/keyword.item.html:37
+msgid "Keyword"
+msgstr "Palabra clave"
+
+#: ../templates/classic/html/msg.index.html:3
+msgid "List of messages - ${tracker}"
+msgstr "Lista de mensajes - ${tracker}"
+
+#: ../templates/classic/html/msg.index.html:5
+msgid "Message listing"
+msgstr "Listado de mensajes"
+
+#: ../templates/classic/html/msg.item.html:6
+msgid "Message ${id} - ${tracker}"
+msgstr "Mensaje ${id} - ${tracker}"
+
+#: ../templates/classic/html/msg.item.html:9
+msgid "New Message - ${tracker}"
+msgstr "Nuevo mensaje - ${tracker}"
+
+#: ../templates/classic/html/msg.item.html:13
+msgid "New Message"
+msgstr "Nuevo mensaje"
+
+#: ../templates/classic/html/msg.item.html:15
+msgid "New Message Editing"
+msgstr "Edición de nuevo mensaje"
+
+#: ../templates/classic/html/msg.item.html:18
+msgid "Message${id}"
+msgstr "Mensaje${id}"
+
+#: ../templates/classic/html/msg.item.html:21
+msgid "Message${id} Editing"
+msgstr "Edición de Mensaje${id}"
+
+#: ../templates/classic/html/msg.item.html:33
+msgid "Author"
+msgstr "Autor"
+
+#: ../templates/classic/html/msg.item.html:38
+msgid "Recipients"
+msgstr "Destinatarios"
+
+#: ../templates/classic/html/msg.item.html:49
+msgid "Content"
+msgstr "Contenido"
+
+#: ../templates/classic/html/page.html:41
+msgid "<b>Your Queries</b> (<a href=\"query?@template=edit\">edit</a>)"
+msgstr "<b>Sus consultas</b> (<a href=\"query?@template=edit\">editar</a>)"
+
+#: ../templates/classic/html/page.html:52
+msgid "Issues"
+msgstr ""
+
+#: ../templates/classic/html/page.html:54
+#: ../templates/classic/html/page.html:74
+msgid "Create New"
+msgstr "Crear"
+
+#: ../templates/classic/html/page.html:56
+msgid "Show Unassigned"
+msgstr "Mostrar no asignados"
+
+#: ../templates/classic/html/page.html:58
+msgid "Show All"
+msgstr "Mostrar todos"
+
+#: ../templates/classic/html/page.html:61
+msgid "Show issue:"
+msgstr "Mostrar issue:"
+
+#: ../templates/classic/html/page.html:72
+msgid "Keywords"
+msgstr "Palabras clave"
+
+#: ../templates/classic/html/page.html:78
+msgid "Edit Existing"
+msgstr "Editar existentes"
+
+#: ../templates/classic/html/page.html:84
+#: ../templates/minimal/html/page.html:62
+msgid "Administration"
+msgstr "Administración"
+
+#: ../templates/classic/html/page.html:86
+#: ../templates/minimal/html/page.html:63
+msgid "Class List"
+msgstr "Lista de clases"
+
+#: ../templates/classic/html/page.html:90
+#: ../templates/minimal/html/page.html:65
+msgid "User List"
+msgstr "Lista de usuarios"
+
+#: ../templates/classic/html/page.html:92
+#: ../templates/minimal/html/page.html:68
+msgid "Add User"
+msgstr "Agregar usuario"
+
+#: ../templates/classic/html/page.html:99
+#: ../templates/classic/html/page.html:103
+#: ../templates/minimal/html/page.html:44
+msgid "Login"
+msgstr "Ingresar"
+
+#: ../templates/classic/html/page.html:105
+#: ../templates/classic/html/user.register.html:63
+#: ../templates/minimal/html/page.html:47
+#: ../templates/minimal/html/user.register.html:58
+msgid "Register"
+msgstr "Registrarse"
+
+#: ../templates/classic/html/page.html:108
+msgid "Lost&nbsp;your&nbsp;login?"
+msgstr "Olvidó&nbsp;su&nbsp;contraseña?"
+
+#: ../templates/classic/html/page.html:113
+msgid "Hello, ${user}"
+msgstr "Hola, ${user}"
+
+#: ../templates/classic/html/page.html:115
+msgid "Your Issues"
+msgstr "Sus Issues"
+
+#: ../templates/classic/html/page.html:116
+#: ../templates/minimal/html/page.html:54
+msgid "Your Details"
+msgstr "Sus datos personales"
+
+#: ../templates/classic/html/page.html:118
+#: ../templates/minimal/html/page.html:56
+msgid "Logout"
+msgstr "Salir"
+
+#: ../templates/classic/html/page.html:122
+msgid "Help"
+msgstr "Ayuda"
+
+#: ../templates/classic/html/page.html:123
+msgid "Roundup docs"
+msgstr "Doc. de Roundup"
+
+#: ../templates/classic/html/page.html:174
+msgid "don't care"
+msgstr "cualquier(a)"
+
+#: ../templates/classic/html/page.html:176
+msgid "------------"
+msgstr ""
+
+#: ../templates/classic/html/page.html:203
+msgid "no value"
+msgstr "sin valor"
+
+#: ../templates/classic/html/query.edit.html:4
+msgid "\"Your Queries\" Editing - ${tracker}"
+msgstr "Edición de \"Sus consultas\" - ${tracker}"
+
+#: ../templates/classic/html/query.edit.html:6
+msgid "\"Your Queries\" Editing"
+msgstr "Edición de \"Sus consultas\""
+
+#: ../templates/classic/html/query.edit.html:11
+msgid "You are not allowed to edit queries."
+msgstr "Ud. no posee los permisos necesarios para editar consultas."
+
+#: ../templates/classic/html/query.edit.html:28
+msgid "Query"
+msgstr "Consulta"
+
+#: ../templates/classic/html/query.edit.html:29
+msgid "Include in \"Your Queries\""
+msgstr "Incluir en \"Sus consultas\""
+
+#: ../templates/classic/html/query.edit.html:31
+msgid "Private to you?"
+msgstr "Privada a Ud.?"
+
+#: ../templates/classic/html/query.edit.html:44
+msgid "leave out"
+msgstr "no incluir"
+
+#: ../templates/classic/html/query.edit.html:45
+msgid "include"
+msgstr "incluir"
+
+#: ../templates/classic/html/query.edit.html:49
+msgid "leave in"
+msgstr "incluir"
+
+#: ../templates/classic/html/query.edit.html:54
+msgid "[query is retired]"
+msgstr "[consulta retirada]"
+
+#: ../templates/classic/html/query.edit.html:67
+msgid "edit"
+msgstr "editar"
+
+#: ../templates/classic/html/query.edit.html:71
+msgid "yes"
+msgstr "si"
+
+#: ../templates/classic/html/query.edit.html:73
+msgid "no"
+msgstr "no"
+
+#: ../templates/classic/html/query.edit.html:79
+msgid "Delete"
+msgstr "Eliminar"
+
+#: ../templates/classic/html/query.edit.html:90
+msgid "[not yours to edit]"
+msgstr "[no puede editar una consulta que no le pertenece]"
+
+#: ../templates/classic/html/query.edit.html:96
+msgid "Save Selection"
+msgstr "Guardar selección"
+
+#: ../templates/classic/html/user.forgotten.html:3
+msgid "Password reset request - ${tracker}"
+msgstr "Solicitud de generación de nueva contraseña - ${tracker}"
+
+#: ../templates/classic/html/user.forgotten.html:5
+msgid "Password reset request"
+msgstr "solicitud de generación de nueva contraseña"
+
+#: ../templates/classic/html/user.forgotten.html:9
+msgid "You have two options if you have forgotten your password. If you know the email address you registered with, enter it below."
+msgstr "Si ha olvidado su contraseña dispone de dos opciones. Si recuerda la dirección de e-mail con la que se registró, ingrésela abajo."
+
+#: ../templates/classic/html/user.forgotten.html:16
+msgid "Email Address:"
+msgstr "Dirección de e-mail:"
+
+#: ../templates/classic/html/user.forgotten.html:24
+#: ../templates/classic/html/user.forgotten.html:34
+msgid "Request password reset"
+msgstr "Solicitar generación nueva contraseña"
+
+#: ../templates/classic/html/user.forgotten.html:30
+msgid "Or, if you know your username, then enter it below."
+msgstr "O, si conoce su nombre de usuario, ingréselo abajo."
+
+#: ../templates/classic/html/user.forgotten.html:33
+msgid "Username:"
+msgstr "Nombre de usuario:"
+
+#: ../templates/classic/html/user.forgotten.html:39
+msgid "A confirmation email will be sent to you - please follow the instructions within it to complete the reset process."
+msgstr "Se le enviará un mensaje de e-mail - por favor siga las instrucciones detalladas en el mismo para completar el proceso de generación de nueva una contraseña."
+
+#: ../templates/classic/html/user.index.html:3
+#: ../templates/minimal/html/user.index.html:3
+msgid "User listing - ${tracker}"
+msgstr "Listado de usuarios - ${tracker}"
+
+#: ../templates/classic/html/user.index.html:5
+#: ../templates/minimal/html/user.index.html:5
+msgid "User listing"
+msgstr "Listado de usuarios"
+
+#: ../templates/classic/html/user.index.html:14
+#: ../templates/minimal/html/user.index.html:14
+msgid "Username"
+msgstr "Nombre de usuario"
+
+#: ../templates/classic/html/user.index.html:15
+msgid "Real name"
+msgstr "Nombre real"
+
+#: ../templates/classic/html/user.index.html:16
+#: ../templates/classic/html/user.item.html:70
+#: ../templates/classic/html/user.register.html:45
+msgid "Organisation"
+msgstr "Organización"
+
+#: ../templates/classic/html/user.index.html:17
+#: ../templates/minimal/html/user.index.html:15
+msgid "Email address"
+msgstr "Dirección de e-mail"
+
+#: ../templates/classic/html/user.index.html:18
+msgid "Phone number"
+msgstr "Nro. telefónico"
+
+#: ../templates/classic/html/user.index.html:19
+msgid "Retire"
+msgstr "Retirar"
+
+#: ../templates/classic/html/user.index.html:32
+msgid "retire"
+msgstr "retirar"
+
+#: ../templates/classic/html/user.item.html:7
+msgid "User ${id}: ${title} - ${tracker}"
+msgstr "Usuario ${id}: ${title} - ${tracker}"
+
+#: ../templates/classic/html/user.item.html:10
+msgid "New User - ${tracker}"
+msgstr "Nuevo usuario - ${tracker}"
+
+#: ../templates/classic/html/user.item.html:14
+#: ../templates/minimal/html/user.item.html:6
+msgid "New User"
+msgstr "Nuevo usuario"
+
+#: ../templates/classic/html/user.item.html:16
+#: ../templates/minimal/html/user.item.html:8
+msgid "New User Editing"
+msgstr "Edición de nuevo usuario"
+
+#: ../templates/classic/html/user.item.html:19
+#: ../templates/minimal/html/user.item.html:11
+msgid "User${id}"
+msgstr "Usuario${id}"
+
+#: ../templates/classic/html/user.item.html:22
+#: ../templates/minimal/html/user.item.html:14
+msgid "User${id} Editing"
+msgstr "Edición de Usuario${id}"
+
+#: ../templates/classic/html/user.item.html:43
+#: ../templates/classic/html/user.register.html:21
+#: ../templates/minimal/html/user.item.html:27
+#: ../templates/minimal/html/user.item.html:67
+#: ../templates/minimal/html/user.register.html:26
+msgid "Login Name"
+msgstr "Nombre para Login"
+
+#: ../templates/classic/html/user.item.html:47
+#: ../templates/classic/html/user.register.html:25
+#: ../templates/minimal/html/user.item.html:31
+#: ../templates/minimal/html/user.register.html:30
+msgid "Login Password"
+msgstr "Contraseña para Login"
+
+#: ../templates/classic/html/user.item.html:51
+#: ../templates/classic/html/user.register.html:29
+#: ../templates/minimal/html/user.item.html:35
+#: ../templates/minimal/html/user.register.html:34
+msgid "Confirm Password"
+msgstr "Confirmar contraseña"
+
+#: ../templates/classic/html/user.item.html:55
+#: ../templates/classic/html/user.register.html:33
+#: ../templates/minimal/html/user.item.html:39
+#: ../templates/minimal/html/user.register.html:38
+msgid "Roles"
+msgstr "Roles"
+
+#: ../templates/classic/html/user.item.html:61
+msgid "(to give the user more than one role, enter a comma,separated,list)"
+msgstr "(para asignar más de un rol al usuario, ingrese una lista de los mismos separados por comas)"
+
+#: ../templates/classic/html/user.item.html:66
+#: ../templates/classic/html/user.register.html:41
+msgid "Phone"
+msgstr "Teléfono"
+
+#: ../templates/classic/html/user.item.html:74
+msgid "Timezone"
+msgstr "Zona horaria"
+
+#: ../templates/classic/html/user.item.html:78
+msgid "(this is a numeric hour offset, the default is ${zone})"
+msgstr "(este es un valor numérico de diferencia horaria, el valor por defecto es ${zone})"
+
+#: ../templates/classic/html/user.item.html:83
+#: ../templates/classic/html/user.register.html:49
+#: ../templates/minimal/html/user.item.html:47
+#: ../templates/minimal/html/user.item.html:71
+#: ../templates/minimal/html/user.register.html:46
+msgid "E-mail address"
+msgstr "Dirección de e-mail"
+
+#: ../templates/classic/html/user.item.html:87
+#: ../templates/classic/html/user.register.html:53
+#: ../templates/minimal/html/user.item.html:51
+#: ../templates/minimal/html/user.register.html:50
+msgid "Alternate E-mail addresses<br>One address per line"
+msgstr "Direcciones de e-mail alternativas <br>Una dirección por línea"
+
+#: ../templates/classic/html/user.register.html:4
+#: ../templates/classic/html/user.register.html:7
+#: ../templates/minimal/html/user.register.html:4
+#: ../templates/minimal/html/user.register.html:7
+msgid "Registering with ${tracker}"
+msgstr "Registrándose en ${tracker}"
+
+#: ../templates/classic/html/user.rego_progress.html:4
+#: ../templates/minimal/html/user.rego_progress.html:4
+msgid "Registration in progress - ${tracker}"
+msgstr "Registro en marcha - ${tracker}"
+
+#: ../templates/classic/html/user.rego_progress.html:6
+#: ../templates/minimal/html/user.rego_progress.html:6
+msgid "Registration in progress..."
+msgstr "Registro en marcha..."
+
+#: ../templates/classic/html/user.rego_progress.html:10
+#: ../templates/minimal/html/user.rego_progress.html:10
+msgid "You will shortly receive an email to confirm your registration. To complete the registration process, visit the link indicated in the email."
+msgstr "En breve recibirá un mensaje de e-mail para confirmar su registro. Para completar el proceso, visite el enlace indicado en dicho mensaje."
+
+#: ../templates/minimal/html/home.html:2
+msgid "Tracker home - ${tracker}"
+msgstr "Directorio base del tracker - ${tracker}"
+
+#: ../templates/minimal/html/home.html:4
+msgid "Tracker home"
+msgstr "Directorio base del tracker"
+
+#: ../templates/minimal/html/home.html:16
+msgid "Please select from one of the menu options on the left."
+msgstr "Por favor seleccione entre las opciones del menú a la izquierda."
+
+#: ../templates/minimal/html/home.html:19
+msgid "Please log in or register."
+msgstr "Por favor ingrese al sistema o regístrese en el mismo."
+
+#: ../templates/minimal/html/page.html:52
+msgid "Hello,<br>${user}"
+msgstr "Hola,<br>${user}"
+
+#: ../templates/minimal/html/user.item.html:3
+msgid "User editing - ${tracker}"
+msgstr "Edición de usuario - ${tracker}"
+

Added: tracker/vendor/roundup/current/locale/fr.po
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/locale/fr.po	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,3111 @@
+# French message file for Roundup Issue Tracker
+# Georges Martin <georges.martin at pi.be>, 2004.
+# This file is distributed under the same license as the Roundup package.
+# 
+# $Id: fr.po,v 1.4 2006/03/25 16:04:36 a1s Exp $
+# roundup.pot revision 1.17
+# 
+msgid ""
+msgstr ""
+"Project-Id-Version: Roundup 1.1.1\n"
+"Report-Msgid-Bugs-To: roundup-devel at lists.sourceforge.net\n"
+"POT-Creation-Date: 2006-02-28 07:44+0200\n"
+"PO-Revision-Date: 2006-03-22 12:50 GMT+1\n"
+"Last-Translator: Patrick Decat <pdecat at gmail.com>\n"
+"Language-Team: French Translators <roundup-devel at lists.sourceforge.net>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=ISO-8859-1\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n !=1;\n"
+
+# ../roundup/admin.py:85 :979 :1028 :1050
+#: ../roundup/admin.py:85 ../roundup/admin.py:979 ../roundup/admin.py:1028
+#: ../roundup/admin.py:1050
+#, python-format
+msgid "no such class \"%(classname)s\""
+msgstr "la classe \"%(classname)s\" n'existe pas"
+
+# ../roundup/admin.py:95 :99
+#: ../roundup/admin.py:95 ../roundup/admin.py:99
+#, python-format
+msgid "argument \"%(arg)s\" not propname=value"
+msgstr "l'argument \"%(arg)s\" n'est pas au format nom-de-propriété=valeur"
+
+#: ../roundup/admin.py:112
+#, python-format
+msgid ""
+"Problem: %(message)s\n"
+"\n"
+msgstr ""
+"Problème: %(message)s\n"
+"\n"
+
+#: ../roundup/admin.py:113
+#, python-format
+msgid ""
+"%(message)sUsage: roundup-admin [options] [<command> <arguments>]\n"
+"\n"
+"Options:\n"
+" -i instance home  -- specify the issue tracker \"home directory\" to "
+"administer\n"
+" -u                -- the user[:password] to use for commands\n"
+" -d                -- print full designators not just class id numbers\n"
+" -c                -- when outputting lists of data, comma-separate them.\n"
+"                      Same as '-S \",\"'.\n"
+" -S <string>       -- when outputting lists of data, string-separate them\n"
+" -s                -- when outputting lists of data, space-separate them.\n"
+"                      Same as '-S \" \"'.\n"
+"\n"
+" Only one of -s, -c or -S can be specified.\n"
+"\n"
+"Help:\n"
+" roundup-admin -h\n"
+" roundup-admin help                       -- this help\n"
+" roundup-admin help <command>             -- command-specific help\n"
+" roundup-admin help all                   -- all available help\n"
+msgstr ""
+"%(message)sUsage: roundup-admin [options] [<commande> <arguments>]\n"
+"\n"
+"Options:\n"
+" -i base-du-pisteur -- spécifie le répertoire de base du pisteur à\n"
+"                       administrer.\n"
+" -u                 -- le nom-d'utilisateur[:mot-de-passe] à utiliser\n"
+"                       pour les commandes.\n"
+" -d                 -- imprime les indicateurs complets, pas seulement\n"
+"                       les numéros d'identification de classe.\n"
+" -c                 -- imprime les listes de données en les séparant par\n"
+"                       des virgules.\n"
+"                       Identique à '-S \",\"'.\n"
+" -S <chaîne>        -- imprime les listes de données en les séparant par\n"
+"                       la chaîne spécifiée.\n"
+" -s                 -- imprime les listes de données en les séparant par\n"
+"                       des espaces.\n"
+"                       Identique à '-S \" \"'.\n"
+"\n"
+" Une seule des options -s, -c ou -S peut être spécifiée.\n"
+"\n"
+"Aide:\n"
+" roundup-admin -h\n"
+" roundup-admin help                       -- cette aide\n"
+" roundup-admin help <commande>            -- l'aide sur une commande\n"
+" roundup-admin help all                   -- toute l'aide disponible\n"
+
+#: ../roundup/admin.py:138
+msgid "Commands:"
+msgstr "Commandes:"
+
+#: ../roundup/admin.py:145
+msgid ""
+"Commands may be abbreviated as long as the abbreviation\n"
+"matches only one command, e.g. l == li == lis == list."
+msgstr ""
+"Les commandes peuvent être abrégées, pour autant\n"
+"que l'abréviation ne corresponde qu'à une seule commande,\n"
+"par ex.: l == li == lis == list."
+
+#: ../roundup/admin.py:175
+msgid ""
+"\n"
+"All commands (except help) require a tracker specifier. This is just\n"
+"the path to the roundup tracker you're working with. A roundup tracker\n"
+"is where roundup keeps the database and configuration file that defines\n"
+"an issue tracker. It may be thought of as the issue tracker's \"home\n"
+"directory\". It may be specified in the environment variable TRACKER_HOME\n"
+"or on the command line as \"-i tracker\".\n"
+"\n"
+"A designator is a classname and a nodeid concatenated, eg. bug1, user10, ...\n"
+"\n"
+"Property values are represented as strings in command arguments and in the\n"
+"printed results:\n"
+" . Strings are, well, strings.\n"
+" . Date values are printed in the full date format in the local time zone,\n"
+"   and accepted in the full format or any of the partial formats explained\n"
+"   below.\n"
+" . Link values are printed as node designators. When given as an argument,\n"
+"   node designators and key strings are both accepted.\n"
+" . Multilink values are printed as lists of node designators joined\n"
+"   by commas.  When given as an argument, node designators and key\n"
+"   strings are both accepted; an empty string, a single node, or a list\n"
+"   of nodes joined by commas is accepted.\n"
+"\n"
+"When property values must contain spaces, just surround the value with\n"
+"quotes, either ' or \". A single space may also be backslash-quoted. If a\n"
+"value must contain a quote character, it must be backslash-quoted or inside\n"
+"quotes. Examples:\n"
+"           hello world      (2 tokens: hello, world)\n"
+"           \"hello world\"    (1 token: hello world)\n"
+"           \"Roch'e\" Compaan (2 tokens: Roch'e Compaan)\n"
+"           Roch\\'e Compaan  (2 tokens: Roch'e Compaan)\n"
+"           address=\"1 2 3\"  (1 token: address=1 2 3)\n"
+"           \\\\               (1 token: \\)\n"
+"           \\n\\r\\t           (1 token: a newline, carriage-return and tab)\n"
+"\n"
+"When multiple nodes are specified to the roundup get or roundup set\n"
+"commands, the specified properties are retrieved or set on all the listed\n"
+"nodes.\n"
+"\n"
+"When multiple results are returned by the roundup get or roundup find\n"
+"commands, they are printed one per line (default) or joined by commas (with\n"
+"the -c) option.\n"
+"\n"
+"Where the command changes data, a login name/password is required. The\n"
+"login may be specified as either \"name\" or \"name:password\".\n"
+" . ROUNDUP_LOGIN environment variable\n"
+" . the -u command-line option\n"
+"If either the name or password is not supplied, they are obtained from the\n"
+"command-line.\n"
+"\n"
+"Date format examples:\n"
+"  \"2000-04-17.03:45\" means <Date 2000-04-17.08:45:00>\n"
+"  \"2000-04-17\" means <Date 2000-04-17.00:00:00>\n"
+"  \"01-25\" means <Date yyyy-01-25.00:00:00>\n"
+"  \"08-13.22:13\" means <Date yyyy-08-14.03:13:00>\n"
+"  \"11-07.09:32:43\" means <Date yyyy-11-07.14:32:43>\n"
+"  \"14:25\" means <Date yyyy-mm-dd.19:25:00>\n"
+"  \"8:47:11\" means <Date yyyy-mm-dd.13:47:11>\n"
+"  \".\" means \"right now\"\n"
+"\n"
+"Command help:\n"
+msgstr ""
+"\n"
+"Toutes les commandes (à l'exception de \"help\") nécessitent\n"
+"un spécificateur de pisteur. Il s'agit juste du chemin vers le pisteur\n"
+"roundup sur lequel vous désirez travailler. Un pisteur roundup est "
+"l'endroit\n"
+"où roundup garde la base de données et les fichiers de configuration qui\n"
+"définissent un pisteur de problèmes.Il peut être spécifié dans la variable "
+"d'environnement \"TRACKER_HOME\" ou\n"
+"dans la ligne de commande comme \"-i base-du-pisteur\".\n"
+"\n"
+"Un indicateur est la concaténation d'un nom de classe et d'un\n"
+"identificateur de noeud, par ex: bug1, user10,...\n"
+"\n"
+"Les valeurs de propriété sont représentés comme des chaînes de caractères\n"
+"dans les arguments de commande et dans les résultats imprimés:\n"
+" . les chaînes de caractères sont représentées telles quelles.\n"
+" . les dates sont imprimées dans le format de date complet avec le fuseau\n"
+"   horaire local et acceptées dans le format complet ou l'un des formats\n"
+"   partiels expliqués ci-dessous. . les valeurs de lien sont imprimées comme "
+"indicateurs de noeuds.\n"
+"   Lorsqu'ils sont donnés comme arguments, les indicateurs de noeuds\n"
+"   et les chaînes de clés sont tout deux acceptés.\n"
+" . les valeurs des liens multiples sont imprimées comme listes de\n"
+"   désignateurs de noeuds, séparés par des virgules. Lorsqu'ils sont\n"
+"   donnés comme arguments, des désignateurs de noeuds ou des clés\n"
+"   sous forme de chaîne de caractères sont acceptés; une chaîne de "
+"caractères\n"
+"   vide, un noeud seul ou une liste de noeuds séparés par des virgules \n"
+"   sont acceptés.\n"
+"\n"
+"Lorsque des valeurs de propriétés doivent contenir des espaces, entourez\n"
+"simplement la valeur avec des guillements simples ('') ou doubles (\"\").\n"
+"Un espace seul peut également être \"backslash-quoted\". Si une valeur doit\n"
+"contenir une caractère de \"quoting\", il doit être \"backslash-quoted\" ou "
+"être\n"
+"placé entre guillemets. Par ex.:\n"
+"           hello world        (2 éléments: hello, world)\n"
+"           \"hello world\"    (1 élément: hello world)\n"
+"           \"Roch'e\" Compaan (2 éléments: Roch'e Compaan)\n"
+"           Roch\\'e Compaan   (2 éléments: Roch'e Compaan)\n"
+"           address=\"1 2 3\"  (1 élément: address=1 2 3)\n"
+"           \\\\               (1 élément: \\)\n"
+"           \\n\\r\\t          (1 élément: un passage à la ligne, un\n"
+"                               retour-chariot et une tabulation)\n"
+"\n"
+"Lorsque plusieurs noeuds sont spécifiés aux commandes roundup \"get\"\n"
+"ou \"set\", les propriétés spécifiées sont extraites ou assignées à\n"
+"tout ces noeuds.\n"
+"\n"
+"Lorsque plusieurs résultats sont renvoyés par les commandes roundup \"get\"\n"
+"ou \"set\", ils sont, par défaut, imprimés un par ligne ou, avec \n"
+"l'option -c, séparés par des virgules.\n"
+"\n"
+"Lorsqu'une commande change des données, une authentification par nom et mot "
+"de\n"
+"passe est requise. L'authentification peut être donnée soit comme \"nom\",\n"
+"soit comme \"nom:mot-de-passe.\n"
+" . comme variable d'environnement ROUNDUP_LOGIN\n"
+" . comme option -u dans la ligne de commande\n"
+"Si le nom ou le mot de passe ne sont pas fournis, ils sont demandés à la\n"
+"ligne de commande\n"
+"\n"
+"Quelques exemples de dates:\n"
+"  \"2000-04-17.03:45\" donne <Date 2000-04-17.08:45:00>\n"
+"  \"2000-04-17\" donne <Date 2000-04-17.00:00:00>\n"
+"  \"01-25\" donne <Date yyyy-01-25.00:00:00>\n"
+"  \"08-13.22:13\" donne <Date yyyy-08-14.03:13:00>\n"
+"  \"11-07.09:32:43\" donne <Date yyyy-11-07.14:32:43>\n"
+"  \"14:25\" donne <Date yyyy-mm-dd.19:25:00>\n"
+"  \"8:47:11\" donne <Date yyyy-mm-dd.13:47:11>\n"
+"  \".\" donne \"maintenant\"\n"
+"\n"
+"Aide sur les commandes:\n"
+
+#: ../roundup/admin.py:238
+#, python-format
+msgid "%s:"
+msgstr "%s:"
+
+#: ../roundup/admin.py:243
+msgid ""
+"Usage: help topic\n"
+"        Give help about topic.\n"
+"\n"
+"        commands  -- list commands\n"
+"        <command> -- help specific to a command\n"
+"        initopts  -- init command options\n"
+"        all       -- all available help\n"
+"        "
+msgstr ""
+"Usage: help sujet\n"
+"        Donne de l'aide sur un sujet.\n"
+"\n"
+"        commands   -- liste les commandes\n"
+"        <commande> -- aide spécifique à une commande\n"
+"        initopts   -- options des commandes d'initialisation\n"
+"        all        -- toute l'aide disponible\n"
+"        "
+
+#: ../roundup/admin.py:266
+#, python-format
+msgid "Sorry, no help for \"%(topic)s\""
+msgstr "Désolé, aucune aide n'est disponible à propos de \"%(topic)s\""
+
+# ../roundup/admin.py:338 :394
+#: ../roundup/admin.py:338 ../roundup/admin.py:394
+msgid "Templates:"
+msgstr "Modèles:"
+
+# ../roundup/admin.py:341 :405
+#: ../roundup/admin.py:341 ../roundup/admin.py:405
+msgid "Back ends:"
+msgstr "Moteurs de stockage:"
+
+#: ../roundup/admin.py:344
+msgid ""
+"Usage: install [template [backend [admin password [key=val[,key=val]]]]]\n"
+"        Install a new Roundup tracker.\n"
+"\n"
+"        The command will prompt for the tracker home directory\n"
+"        (if not supplied through TRACKER_HOME or the -i option).\n"
+"        The template, backend and admin password may be specified\n"
+"        on the command-line as arguments, in that order.\n"
+"\n"
+"        The last command line argument allows to pass initial values\n"
+"        for config options.  For example, passing\n"
+"        \"web_http_auth=no,rdbms_user=dinsdale\" will override defaults\n"
+"        for options http_auth in section [web] and user in section [rdbms].\n"
+"        Please be careful to not use spaces in this argument! (Enclose\n"
+"        whole argument in quotes if you need spaces in option value).\n"
+"\n"
+"        The initialise command must be called after this command in order\n"
+"        to initialise the tracker's database. You may edit the tracker's\n"
+"        initial database contents before running that command by editing\n"
+"        the tracker's dbinit.py module init() function.\n"
+"\n"
+"        See also initopts help.\n"
+"        "
+msgstr ""
+"Usage: install [template [backend [admin password [key=val[,key=val]]]]]\n"
+"        Installe un nouveau pisteur Roundup.\n"
+"\n"
+"        Cette commande demandera le répertoire de base du pisteur (s'il\n"
+"        n'est pas fourni par la variable d'environnement TRACKER_HOME\n"
+"        ou l'option -i).\n"
+"        Le modèle, le moteur de stockage et le mot de passe\n"
+"        d'administration peuvent être spécifiés dans cet ordre comme\n"
+"        arguments dans la ligne de commande.\n"
+"\n"
+"        Le dernier argument de la ligne de commande permet de préciser des\n"
+"        valeurs initiales pour les options de configuration. Par exemple,\n"
+"        \"web_http_auth=no,rdbms_user=dinsdale\" remplacera les valeurs par\n"
+"        défaut pour les options http_auth dans la section [web] and user\n"
+"        dans la in section [rdbms]. Soyez attentifs à ne pas mettre d'espace\n"
+"        dans cet argument! (Entourez-le de quotes si vous devez préciser des\n"
+"        valeurs contenant des espaces).\n"
+"\n"
+"        La commande \"initialise\" doît être appelée après cette commande,\n"
+"        pour initialiser la base de données du pisteur. Vous pouvez éditer\n"
+"        le contenu initial de la base de données avant d'exécuter cette\n"
+"        commande en modifiant la fonction init() du module dbinit.py du\n"
+"        pisteur.\n"
+"\n"
+"        Voyez également l'aide sur \"initopts\".\n"
+"        "
+
+# ../roundup/admin.py:367 :464 :525 :604 :654 :712 :733 :761 :832 :899 :970
+# :1018 :1040 :1067 :1134 :1204
+#: ../roundup/admin.py:367 ../roundup/admin.py:464 ../roundup/admin.py:525
+#: ../roundup/admin.py:604 ../roundup/admin.py:654 ../roundup/admin.py:712
+#: ../roundup/admin.py:733 ../roundup/admin.py:761 ../roundup/admin.py:832
+#: ../roundup/admin.py:899 ../roundup/admin.py:970 ../roundup/admin.py:1018
+#: ../roundup/admin.py:1040 ../roundup/admin.py:1067 ../roundup/admin.py:1134
+#: ../roundup/admin.py:1204
+msgid "Not enough arguments supplied"
+msgstr "Pas assez d'arguments fournis"
+
+#: ../roundup/admin.py:373
+#, python-format
+msgid "Instance home parent directory \"%(parent)s\" does not exist"
+msgstr "Le répertoire parent \"%(parent)s\" de l'instance de base n'existe pas"
+
+#: ../roundup/admin.py:381
+#, python-format
+msgid ""
+"WARNING: There appears to be a tracker in \"%(tracker_home)s\"!\n"
+"If you re-install it, you will lose all the data!\n"
+"Erase it? Y/N: "
+msgstr ""
+"ATTENTION: Il semble qu'il y ait déjà un pisteur dans \"%(tracker_home)s\"!\n"
+"Si vous le réinstallez, vous perdrez toutes les données !\n"
+"Effacer le pisteur ? Y/N: "
+
+#: ../roundup/admin.py:396
+msgid "Select template [classic]: "
+msgstr "Sélectionnez un modèle [classic]: "
+
+#: ../roundup/admin.py:407
+msgid "Select backend [anydbm]: "
+msgstr "Sélectionnez un moteur de stockage [anydbm]: "
+
+#: ../roundup/admin.py:417
+#, python-format
+msgid "Error in configuration settings: \"%s\""
+msgstr "Erreur dans les paramètres de la configuration : \"%s\""
+
+#: ../roundup/admin.py:426
+#, python-format
+msgid ""
+"\n"
+"---------------------------------------------------------------------------\n"
+" You should now edit the tracker configuration file:\n"
+"   %(config_file)s"
+msgstr ""
+
+#: ../roundup/admin.py:436
+msgid " ... at a minimum, you must set following options:"
+msgstr ""
+
+#: ../roundup/admin.py:441
+#, python-format
+msgid ""
+"\n"
+" If you wish to modify the database schema,\n"
+" you should also edit the schema file:\n"
+"   %(database_config_file)s\n"
+" You may also change the database initialisation file:\n"
+"   %(database_init_file)s\n"
+" ... see the documentation on customizing for more information.\n"
+"\n"
+" You MUST run the \"roundup-admin initialise\" command once you've performed\n"
+" the above steps.\n"
+"---------------------------------------------------------------------------\n"
+msgstr ""
+
+#: ../roundup/admin.py:459
+msgid ""
+"Usage: genconfig <filename>\n"
+"        Generate a new tracker config file (ini style) with default values\n"
+"        in <filename>.\n"
+"        "
+msgstr ""
+
+#. password
+#: ../roundup/admin.py:469
+msgid ""
+"Usage: initialise [adminpw]\n"
+"        Initialise a new Roundup tracker.\n"
+"\n"
+"        The administrator details will be set at this step.\n"
+"\n"
+"        Execute the tracker's initialisation function dbinit.init()\n"
+"        "
+msgstr ""
+"Usage: initialise [adminpw]\n"
+"        Initialise un nouveau pisteur Roundup.\n"
+"\n"
+"        Les détails sur l'administrateur sont réglés au cours de cette\n"
+"        étape.\n"
+"\n"
+"        Exécute la fonction d'initialisation dbinit.init() du pisteur\n"
+"        "
+
+#: ../roundup/admin.py:483
+msgid "Admin Password: "
+msgstr "Mot de passe d'administrateur: "
+
+#: ../roundup/admin.py:484
+msgid "       Confirm: "
+msgstr "       Confirmez: "
+
+#: ../roundup/admin.py:488
+msgid "Instance home does not exist"
+msgstr "Le répertoire de base de l'instance n'existe pas"
+
+#: ../roundup/admin.py:492
+msgid "Instance has not been installed"
+msgstr "L'instance n'a pas été installée"
+
+#: ../roundup/admin.py:497
+msgid ""
+"WARNING: The database is already initialised!\n"
+"If you re-initialise it, you will lose all the data!\n"
+"Erase it? Y/N: "
+msgstr ""
+"ATTENTION: La base de données est déjà initialisée !\n"
+"Si vous la réinitialisez, vous perdrez toutes les données !\n"
+"Effacer la base de données ? Y/N: "
+
+#: ../roundup/admin.py:518
+msgid ""
+"Usage: get property designator[,designator]*\n"
+"        Get the given property of one or more designator(s).\n"
+"\n"
+"        Retrieves the property value of the nodes specified\n"
+"        by the designators.\n"
+"        "
+msgstr ""
+"Usage: get property indicateur[,indicateur]*\n"
+"        Donne la propriété spécifiée d'un ou plusieurs indicateurs.\n"
+"\n"
+"        Donne la valeur de la propriété des noeuds spécifiés par\n"
+"        les indicateurs.\n"
+"        "
+
+# ../roundup/admin.py:558 :573
+#: ../roundup/admin.py:558 ../roundup/admin.py:573
+#, python-format
+msgid "property %s is not of type Multilink or Link so -d flag does not apply."
+msgstr ""
+"la propriété %s n'est pas de type Multilien ou Lien et donc l'option -d ne"
+"s'applique pas."
+
+# ../roundup/admin.py:581 :981 :1030 :1052
+#: ../roundup/admin.py:581 ../roundup/admin.py:981 ../roundup/admin.py:1030
+#: ../roundup/admin.py:1052
+#, python-format
+msgid "no such %(classname)s node \"%(nodeid)s\""
+msgstr "le noeud \"%(nodeid)s\" de classe \"%(classname)s\" n'existe pas"
+
+#: ../roundup/admin.py:583
+#, python-format
+msgid "no such %(classname)s property \"%(propname)s\""
+msgstr ""
+"la propriété \"%(propname)s\" n'existe pas pour la classe \"%(classname)s\""
+
+#: ../roundup/admin.py:592
+msgid ""
+"Usage: set items property=value property=value ...\n"
+"        Set the given properties of one or more items(s).\n"
+"\n"
+"        The items are specified as a class or as a comma-separated\n"
+"        list of item designators (ie \"designator[,designator,...]\").\n"
+"\n"
+"        This command sets the properties to the values for all designators\n"
+"        given. If the value is missing (ie. \"property=\") then the property\n"
+"        is un-set. If the property is a multilink, you specify the linked\n"
+"        ids for the multilink as comma-separated numbers (ie \"1,2,3\").\n"
+"        "
+msgstr ""
+"Usage: set éléments propriété=valeur propriété=valeur ...\n"
+"        Assigne les propriétés données à un ou plusieurs éléments.\n"
+"\n"
+"        Les éléments sont spécifiés par une classe ou par une liste\n"
+"        d'indicateurs séparés par des virgules (par ex.:\n"
+"        \"indicateur[,indicateur,...]\").\n"
+"\n"
+"        Cette commande assigne les valeurs données aux propriétés de tout\n"
+"        les indicateurs spécifiés. Si la valeur est absente (par ex.:\n"
+"        \"propriété=\") alors la propriété est #effacée. Si la propriété\n"
+"        est un lien multiple, les identificateurs attachés à ce lien sont\n"
+"        spécifiés comme des nombres séparés par des virgules (par ex.: \n"
+"        \"1,2,3\").\n"
+"        "
+
+#: ../roundup/admin.py:646
+msgid ""
+"Usage: find classname propname=value ...\n"
+"        Find the nodes of the given class with a given link property value.\n"
+"\n"
+"        Find the nodes of the given class with a given link property value.\n"
+"        The value may be either the nodeid of the linked node, or its key\n"
+"        value.\n"
+"        "
+msgstr ""
+"Usage: find nom-de-classe propriété=valeur ...\n"
+"        Recherche les noeuds de la classe spécifiée, ayant une propriété de\n"
+"        lien donnée.\n"
+"\n"
+"        Recherche les noeuds de la classe spécifiée, ayant une propriété de\n"
+"        lien donnée. La valeur peut être soit l'identificateur de noeud du\n"
+"        noeud lié, ou sa valeur de clé.\n"
+"        "
+
+# ../roundup/admin.py:699 :852 :864 :918
+#: ../roundup/admin.py:699 ../roundup/admin.py:852 ../roundup/admin.py:864
+#: ../roundup/admin.py:918
+#, python-format
+msgid "%(classname)s has no property \"%(propname)s\""
+msgstr "%(classname)s n'a pas de propriété \"%(propname)s\""
+
+#: ../roundup/admin.py:706
+msgid ""
+"Usage: specification classname\n"
+"        Show the properties for a classname.\n"
+"\n"
+"        This lists the properties for a given class.\n"
+"        "
+msgstr ""
+"Usage: specification nom-de-classe\n"
+"        Donne les propriétés pour un nom de classe donné.\n"
+"\n"
+"        Cette commande énumère les propriétés d'une classe donnée.\n"
+"        "
+
+#: ../roundup/admin.py:721
+#, python-format
+msgid "%(key)s: %(value)s (key property)"
+msgstr "%(key)s: %(value)s (propriété clé)"
+
+#: ../roundup/admin.py:723
+#, python-format
+msgid "%(key)s: %(value)s"
+msgstr "%(key)s: %(value)s"
+
+#: ../roundup/admin.py:726
+msgid ""
+"Usage: display designator[,designator]*\n"
+"        Show the property values for the given node(s).\n"
+"\n"
+"        This lists the properties and their associated values for the given\n"
+"        node.\n"
+"        "
+msgstr ""
+"Usage: display indicateur[,indicateur]*\n"
+"        Donne les valeurs des propriétés pour les noeuds spécifiés.\n"
+"\n"
+"        Cette commande énumère les propriétés et leurs valeurs du ou\n"
+"        des noeuds spécifiés.\n"
+"        "
+
+#: ../roundup/admin.py:750
+#, python-format
+msgid "%(key)s: %(value)r"
+msgstr "%(key)s: %(value)r"
+
+#: ../roundup/admin.py:753
+msgid ""
+"Usage: create classname property=value ...\n"
+"        Create a new entry of a given class.\n"
+"\n"
+"        This creates a new entry of the given class using the property\n"
+"        name=value arguments provided on the command line after the \"create"
+"\"\n"
+"        command.\n"
+"        "
+msgstr ""
+"Usage: create nom-de-classe propriété=valeur ...\n"
+"        Crée une nouvelle entrée d'une classe donnée.\n"
+"\n"
+"        Cette commande crée une nouvelle entrée d'une classe spécifiée en\n"
+"        utilisant les propriétés \"nom=valeur\" données en arguments dans\n"
+"        la ligne de commande, après la commande \"create\".\n"
+"        "
+
+#: ../roundup/admin.py:780
+#, python-format
+msgid "%(propname)s (Password): "
+msgstr "%(propname)s (Mot de passe): "
+
+#: ../roundup/admin.py:782
+#, python-format
+msgid "   %(propname)s (Again): "
+msgstr "   %(propname)s (À nouveau): "
+
+#: ../roundup/admin.py:784
+msgid "Sorry, try again..."
+msgstr "Désolé, essayez encore..."
+
+#: ../roundup/admin.py:788
+#, python-format
+msgid "%(propname)s (%(proptype)s): "
+msgstr "%(propname)s (%(proptype)s): "
+
+#: ../roundup/admin.py:806
+#, python-format
+msgid "you must provide the \"%(propname)s\" property."
+msgstr "Vous devez fournir la propriété \"%(propname)s\"."
+
+#: ../roundup/admin.py:817
+msgid ""
+"Usage: list classname [property]\n"
+"        List the instances of a class.\n"
+"\n"
+"        Lists all instances of the given class. If the property is not\n"
+"        specified, the  \"label\" property is used. The label property is\n"
+"        tried in order: the key, \"name\", \"title\" and then the first\n"
+"        property, alphabetically.\n"
+"\n"
+"        With -c, -S or -s print a list of item id's if no property\n"
+"        specified.  If property specified, print list of that property\n"
+"        for every class instance.\n"
+"        "
+msgstr ""
+"Usage: list nom-de-classe [propriété]\n"
+"        Donne toutes les instances d'une classe.\n"
+"\n"
+"        Énumère toutes les instances d'une classe donnée. Si la propriété\n"
+"        n'est pas spécifiée, la propriété \"label\" est utilisée. Cette\n"
+"        propriété étiquette est déterminée selon l'ordre suivant: la clé,\n"
+"        les propriétés \"name\", \"title\" et la première propriété par\n"
+"        ordre alphabétique.\n"
+"\n"
+"        Avec les options -c, -S ou -s, affiche une liste des\n"
+"        identificateurs d'éléments si aucune propriété n'est spécifiée.\n"
+"        Si une propriété est spécifiée, affiche une liste de cette propriété\n"
+"        pour chaque instance de cette classe.\n"
+"        "
+
+#: ../roundup/admin.py:830
+msgid "Too many arguments supplied"
+msgstr "Trop d'arguments fournis"
+
+#: ../roundup/admin.py:866
+#, python-format
+msgid "%(nodeid)4s: %(value)s"
+msgstr "%(nodeid)4s: %(value)s"
+
+#: ../roundup/admin.py:870
+msgid ""
+"Usage: table classname [property[,property]*]\n"
+"        List the instances of a class in tabular form.\n"
+"\n"
+"        Lists all instances of the given class. If the properties are not\n"
+"        specified, all properties are displayed. By default, the column\n"
+"        widths are the width of the largest value. The width may be\n"
+"        explicitly defined by defining the property as \"name:width\".\n"
+"        For example::\n"
+"\n"
+"          roundup> table priority id,name:10\n"
+"          Id Name\n"
+"          1  fatal-bug\n"
+"          2  bug\n"
+"          3  usability\n"
+"          4  feature\n"
+"\n"
+"        Also to make the width of the column the width of the label,\n"
+"        leave a trailing : without a width on the property. For example::\n"
+"\n"
+"          roundup> table priority id,name:\n"
+"          Id Name\n"
+"          1  fata\n"
+"          2  bug\n"
+"          3  usab\n"
+"          4  feat\n"
+"\n"
+"        will result in a the 4 character wide \"Name\" column.\n"
+"        "
+msgstr ""
+"Usage: table nom-de-classe [propriété[,propriété]*]\n"
+"        Liste les instances d'une classe, sous forme de tableau.\n"
+"\n"
+"        Liste toutes les instances d'une classe. Si aucune propriété n'est\n"
+"        spécifiée, toutes les propriétés sont affichées. Par défaut,\n"
+"        les largeurs de colonnes sont de la largeur de la colonne la plus\n"
+"        large. La largeur peut être spécifiée explicitement en définissant\n"
+"        la propriété comme \"nom-de-propriété:largeur\".\n"
+"        Par ex.:\n"
+"\n"
+"          roundup> table priority id,name:10\n"
+"          Id Name\n"
+"          1  fatal-bug\n"
+"          2  bug\n"
+"          3  usability\n"
+"          4  feature\n"
+"\n"
+"        De même, pour fixer la largeur de la colonne sur la largeur de\n"
+"        l'étiquette, laissez le \":\" final sans donner de largeur pour \n"
+"        la propriété. Par ex.::\n"
+"\n"
+"          roundup> table priority id,name:\n"
+"          Id Name\n"
+"          1  fata\n"
+"          2  bug\n"
+"          3  usab\n"
+"          4  feat\n"
+"\n"
+"        donnera une colonne \"Name\" large de 4 caractères.\n"
+"        "
+
+#: ../roundup/admin.py:914
+#, python-format
+msgid "\"%(spec)s\" not name:width"
+msgstr "\"%(spec)s\" ne correspond pas au format \"nom:largeur\""
+
+#: ../roundup/admin.py:964
+msgid ""
+"Usage: history designator\n"
+"        Show the history entries of a designator.\n"
+"\n"
+"        Lists the journal entries for the node identified by the designator.\n"
+"        "
+msgstr ""
+"Usage: history indicateur\n"
+"        Donne l'historique pour un indicateur.\n"
+"\n"
+"        Liste les entrées de journal pour le noeud identifié par\n"
+"        l'indicateur.\n"
+"        "
+
+#: ../roundup/admin.py:985
+msgid ""
+"Usage: commit\n"
+"        Commit changes made to the database during an interactive session.\n"
+"\n"
+"        The changes made during an interactive session are not\n"
+"        automatically written to the database - they must be committed\n"
+"        using this command.\n"
+"\n"
+"        One-off commands on the command-line are automatically committed if\n"
+"        they are successful.\n"
+"        "
+msgstr ""
+"Usage: commit\n"
+"        Valide les changements apportés à la base de données lors d'une\n"
+"        session interactive.\n"
+"\n"
+"        Les changements effectués lors d'une session interactive ne sont\n"
+"        pas automatiquement enregistrés dans la base de données - Ils\n"
+"        doivent être validés par cette commande.\n"
+"\n"
+"        Les commandes \"one-off\" données en ligne de commande sont\n"
+"        automatiquement validées si elles réussissent\n"
+"        "
+
+#: ../roundup/admin.py:999
+msgid ""
+"Usage: rollback\n"
+"        Undo all changes that are pending commit to the database.\n"
+"\n"
+"        The changes made during an interactive session are not\n"
+"        automatically written to the database - they must be committed\n"
+"        manually. This command undoes all those changes, so a commit\n"
+"        immediately after would make no changes to the database.\n"
+"        "
+msgstr ""
+"Usage: rollback\n"
+"        Annule tout les changements en attente de validation pour la base\n"
+"        de données.\n"
+"\n"
+"        Les changements effectués lors d'une session interactive ne sont\n"
+"        pas automatiquement enregistrés dans la base de données - Ils\n"
+"        doivent être validés manuellement. Cette commande annule tout ces\n"
+"        changements, de telle manière qu'une validation effectuée\n"
+"        immédiatement n'apporterait aucun changement à la base de données.\n"
+"        "
+
+#: ../roundup/admin.py:1011
+msgid ""
+"Usage: retire designator[,designator]*\n"
+"        Retire the node specified by designator.\n"
+"\n"
+"        This action indicates that a particular node is not to be retrieved\n"
+"        by the list or find commands, and its key value may be re-used.\n"
+"        "
+msgstr ""
+"Usage: retire indicateur[,indicateur]*\n"
+"        Abandonne le noeud spécifié par l'indicateur.\n"
+"\n"
+"        Cette action indique qu'un noeud particulier ne doit plus être\n"
+"        trouvé par les commandes \"list\" ou \"find\", et que sa valeur\n"
+"        de clé peut être ré-utilisée.\n"
+"        "
+
+#: ../roundup/admin.py:1034
+msgid ""
+"Usage: restore designator[,designator]*\n"
+"        Restore the retired node specified by designator.\n"
+"\n"
+"        The given nodes will become available for users again.\n"
+"        "
+msgstr ""
+"Usage: restore indicateur[,indicateur]*\n"
+"        Restaure le ou les noeuds abandonnés, spécifiés par le ou les\n"
+"        indicateurs.\n"
+"\n"
+"        Les noeuds spécifiés seront à nouveau acessibles aux utilisateurs.\n"
+"        "
+
+#. grab the directory to export to
+#: ../roundup/admin.py:1056
+msgid ""
+"Usage: export [class[,class]] export_dir\n"
+"        Export the database to colon-separated-value files.\n"
+"\n"
+"        Optionally limit the export to just the names classes.\n"
+"\n"
+"        This action exports the current data from the database into\n"
+"        colon-separated-value files that are placed in the nominated\n"
+"        destination directory.\n"
+"        "
+msgstr ""
+"Usage: export [classe[,classe]] répertoire-d'exportation\n"
+"        Exporte la base de données vers des fichiers dans un format\n"
+"        aux valeurs séparées par des double-points.\n"
+"\n"
+"        Limite éventuellement l'exportation aux classes spécifiées.\n"
+"\n"
+"        Cette action exporte les données actuelles de la base de données,\n"
+"        vers des fichiers placés dans le répertoire désigné, et dans un \n"
+"        format aux valeurs séparées par des doubles-points.\n"
+"        "
+
+#: ../roundup/admin.py:1114
+msgid ""
+"Usage: import import_dir\n"
+"        Import a database from the directory containing CSV files,\n"
+"        two per class to import.\n"
+"\n"
+"        The files used in the import are:\n"
+"\n"
+"        <class>.csv\n"
+"          This must define the same properties as the class (including\n"
+"          having a \"header\" line with those property names.)\n"
+"        <class>-journals.csv\n"
+"          This defines the journals for the items being imported.\n"
+"\n"
+"        The imported nodes will have the same nodeid as defined in the\n"
+"        import file, thus replacing any existing content.\n"
+"\n"
+"        The new nodes are added to the existing database - if you want to\n"
+"        create a new database using the imported data, then create a new\n"
+"        database (or, tediously, retire all the old data.)\n"
+"        "
+msgstr ""
+"Usage: import répertoire-d'importation\n"
+"        Importe une base de données à partir d'un répertoire contenant des\n"
+"        fichiers, d'un format aux valeurs séparées par des doubles points,\n"
+"        deux par classe à importer.\n"
+"\n"
+"        Les fichiers utilisés lors de l'importation sont:\n"
+"\n"
+"        <classe>.csv\n"
+"          Celui-ci définit les mêmes propriétés que la classe (avec\n"
+"          une ligne \"header\" donnant ces noms de propriétés).\n"
+"        <classe>-journals.csv\n"
+"          Celui-ci définit les journaux pour les éléments importés.\n"
+"\n"
+"        Les noeuds importés auront les mêmes identificateurs de noeuds\n"
+"        (\"nodeid\") que ceux définis dans le fichier d'importation,\n"
+"        remplaçant dès lors tout contenu existant.\n"
+"\n"
+"        Les nouveaux noeuds sont ajoutés à la base de données - si, en\n"
+"        fait, vous désirez créer une nouvelle base de données avec les\n"
+"        données importées, créez plutôt une nouvelle base de données (ou,\n"
+"        plus péniblement, \"abandonnez\" toutes les anciennes données).\n"
+"        "
+
+#: ../roundup/admin.py:1186
+msgid ""
+"Usage: pack period | date\n"
+"\n"
+"        Remove journal entries older than a period of time specified or\n"
+"        before a certain date.\n"
+"\n"
+"        A period is specified using the suffixes \"y\", \"m\", and \"d\". "
+"The\n"
+"        suffix \"w\" (for \"week\") means 7 days.\n"
+"\n"
+"              \"3y\" means three years\n"
+"              \"2y 1m\" means two years and one month\n"
+"              \"1m 25d\" means one month and 25 days\n"
+"              \"2w 3d\" means two weeks and three days\n"
+"\n"
+"        Date format is \"YYYY-MM-DD\" eg:\n"
+"            2001-01-01\n"
+"\n"
+"        "
+msgstr ""
+"Usage: pack période | date\n"
+"\n"
+"        Efface les entrées de journaux antérieures à une période\n"
+"        ou à une date donnée.\n"
+"\n"
+"        Une période est spécifiée en utilisant les suffixes \"y\" (pour\n"
+"        \"year\" - année), \"m\" (pour \"month\" - mois), et \"d\" (pour\n"
+"        \"day\" - jour).\n"
+"\n"
+"        Le suffixe \"w\" (pour \"week\" - semaine) signifie 7 jours.\n"
+"\n"
+"              \"3y\" signifie 3 ans\n"
+"              \"2y 1m\" signifie 2 ans et un mois\n"
+"              \"1m 25d\" signifie un an et 25 jours\n"
+"              \"2w 3d\" signifie 2 semaines et 3 jours\n"
+"\n"
+"        Le format de date est \"AAAA-MM-JJ\", par ex.:\n"
+"              2001-01-01\n"
+"\n"
+"        "
+
+#: ../roundup/admin.py:1214
+msgid "Invalid format"
+msgstr "Format invalide"
+
+#: ../roundup/admin.py:1224
+msgid ""
+"Usage: reindex [classname|designator]*\n"
+"        Re-generate a tracker's search indexes.\n"
+"\n"
+"        This will re-generate the search indexes for a tracker.\n"
+"        This will typically happen automatically.\n"
+"        "
+msgstr ""
+"Usage: reindex [classname|designator]*\n"
+"        Regénère les index de recherche d'un pisteur.\n"
+"\n"
+"        Cette commande regénèrera les index de recherche d'un pisteur.\n"
+"        Cette manoeuvre doit normalement s'effectuer automatiquement.\n"
+"        "
+
+#: ../roundup/admin.py:1238
+#, python-format
+msgid "no such item \"%(designator)s\""
+msgstr ""
+
+#: ../roundup/admin.py:1248
+msgid ""
+"Usage: security [Role name]\n"
+"        Display the Permissions available to one or all Roles.\n"
+"        "
+msgstr ""
+"Usage: security [nom-de-rôle]\n"
+"        Affiche les permissions disponible pour un ou plusieurs rôles.\n"
+"        "
+
+#: ../roundup/admin.py:1256
+#, python-format
+msgid "No such Role \"%(role)s\""
+msgstr "Ce rôle \"%(role)s\" n'existe pas"
+
+#: ../roundup/admin.py:1262
+#, python-format
+msgid "New Web users get the Roles \"%(role)s\""
+msgstr "Les nouveaux utilisateurs Web ont les rôles \"%(role)s\""
+
+#: ../roundup/admin.py:1264
+#, python-format
+msgid "New Web users get the Role \"%(role)s\""
+msgstr "Les nouveaux utilisateurs Web ont le rôle \"%(role)s\""
+
+#: ../roundup/admin.py:1267
+#, python-format
+msgid "New Email users get the Roles \"%(role)s\""
+msgstr "Les nouveaux utilisateurs Email ont les rôles \"%(role)s\""
+
+#: ../roundup/admin.py:1269
+#, python-format
+msgid "New Email users get the Role \"%(role)s\""
+msgstr "Les nouveaux utilisateurs Email ont le rôle \"%(role)s\""
+
+#: ../roundup/admin.py:1272
+#, python-format
+msgid "Role \"%(name)s\":"
+msgstr "Rôle \"%(name)s\":"
+
+#: ../roundup/admin.py:1277
+#, python-format
+msgid " %(description)s (%(name)s for \"%(klass)s\": %(properties)s only)"
+msgstr ""
+
+#: ../roundup/admin.py:1280
+#, python-format
+msgid " %(description)s (%(name)s for \"%(klass)s\" only)"
+msgstr " %(description)s (%(name)s pour \"%(klass)s\" uniquement)"
+
+#: ../roundup/admin.py:1283
+#, python-format
+msgid " %(description)s (%(name)s)"
+msgstr " %(description)s (%(name)s)"
+
+#: ../roundup/admin.py:1312
+#, python-format
+msgid "Unknown command \"%(command)s\" (\"help commands\" for a list)"
+msgstr "Commande inconnue \"%(command)s\" (\"help commands\" pour la liste)"
+
+#: ../roundup/admin.py:1318
+#, python-format
+msgid "Multiple commands match \"%(command)s\": %(list)s"
+msgstr "Plusieurs commandes correspondent à \"%(command)s\": %(list)s"
+
+#: ../roundup/admin.py:1325
+msgid "Enter tracker home: "
+msgstr "Entrez le répertoire de base du pisteur: "
+
+# ../roundup/admin.py:1332 :1338 :1358
+#: ../roundup/admin.py:1332 ../roundup/admin.py:1338 ../roundup/admin.py:1358
+#, python-format
+msgid "Error: %(message)s"
+msgstr "Erreur: %(message)s"
+
+#: ../roundup/admin.py:1346
+#, python-format
+msgid "Error: Couldn't open tracker: %(message)s"
+msgstr "Erreur: impossible d'ouvrir le pisteur: %(message)s"
+
+#: ../roundup/admin.py:1371
+#, python-format
+msgid ""
+"Roundup %s ready for input.\n"
+"Type \"help\" for help."
+msgstr ""
+"Roundup %s prêt pour l'entrée.\n"
+"Entrez \"help\" pour l'aide."
+
+#: ../roundup/admin.py:1376
+msgid "Note: command history and editing not available"
+msgstr "Note: l'historique et l'édition des commandes n'est pas disponible"
+
+#: ../roundup/admin.py:1380
+msgid "roundup> "
+msgstr "roundup> "
+
+#: ../roundup/admin.py:1382
+msgid "exit..."
+msgstr "sortie..."
+
+#: ../roundup/admin.py:1392
+msgid "There are unsaved changes. Commit them (y/N)? "
+msgstr "Des changements n'ont pas été enregistrés. À valider ? (y/N)? "
+
+#: ../roundup/backends/back_anydbm.py:1997
+#, python-format
+msgid "WARNING: invalid date tuple %r"
+msgstr ""
+
+#: ../roundup/backends/rdbms_common.py:1434
+msgid "create"
+msgstr "créer"
+
+#: ../roundup/backends/rdbms_common.py:1600
+msgid "unlink"
+msgstr "détacher"
+
+#: ../roundup/backends/rdbms_common.py:1604
+msgid "link"
+msgstr "attacher"
+
+#: ../roundup/backends/rdbms_common.py:1724
+msgid "set"
+msgstr "assigner"
+
+#: ../roundup/backends/rdbms_common.py:1748
+msgid "retired"
+msgstr "abandonné"
+
+#: ../roundup/backends/rdbms_common.py:1778
+msgid "restored"
+msgstr "restauré"
+
+#: ../roundup/cgi/actions.py:58
+#, python-format
+msgid "You do not have permission to %(action)s the %(classname)s class."
+msgstr ""
+"Vous n'avez pas les permissions pour %(action)s la classe %(classname)s."
+
+#: ../roundup/cgi/actions.py:89
+msgid "No type specified"
+msgstr "Aucun type spécifié"
+
+#: ../roundup/cgi/actions.py:91
+msgid "No ID entered"
+msgstr "Aucun identifiant entré"
+
+#: ../roundup/cgi/actions.py:97
+#, python-format
+msgid "\"%(input)s\" is not an ID (%(classname)s ID required)"
+msgstr ""
+"\"%(input)s\" n'est pas un identifiant (l'identifiant de %(classname)s est "
+"requis)"
+
+#: ../roundup/cgi/actions.py:117
+msgid "You may not retire the admin or anonymous user"
+msgstr "Vous ne pouvez pas abandonner les utilisateurs admin ou anonyme"
+
+#: ../roundup/cgi/actions.py:124
+#, python-format
+msgid "%(classname)s %(itemid)s has been retired"
+msgstr "%(classname)s %(itemid)s a été abandonné"
+
+# ../roundup/cgi/actions.py:174 :202
+#: ../roundup/cgi/actions.py:174 ../roundup/cgi/actions.py:202
+msgid "You do not have permission to edit queries"
+msgstr ""
+
+# ../roundup/cgi/actions.py:180 :209
+#: ../roundup/cgi/actions.py:180 ../roundup/cgi/actions.py:209
+msgid "You do not have permission to store queries"
+msgstr "Vous n'avez pas la permission de sauvegarder des requêtes"
+
+#: ../roundup/cgi/actions.py:297
+#, python-format
+msgid "Not enough values on line %(line)s"
+msgstr "Pas assez de valeurs sur la ligne %(line)s"
+
+#: ../roundup/cgi/actions.py:344
+msgid "Items edited OK"
+msgstr "Les éléments ont bien été modifiés"
+
+#: ../roundup/cgi/actions.py:404
+#, python-format
+msgid "%(class)s %(id)s %(properties)s edited ok"
+msgstr "%(class)s %(id)s %(properties)s modifié(s)"
+
+#: ../roundup/cgi/actions.py:407
+#, python-format
+msgid "%(class)s %(id)s - nothing changed"
+msgstr "%(class)s %(id)s - aucun changement"
+
+#: ../roundup/cgi/actions.py:419
+#, python-format
+msgid "%(class)s %(id)s created"
+msgstr "%(class)s %(id)s créé"
+
+#: ../roundup/cgi/actions.py:451
+#, python-format
+msgid "You do not have permission to edit %(class)s"
+msgstr "Vous n'avez pas la permission de modifier %(class)s"
+
+#: ../roundup/cgi/actions.py:463
+#, python-format
+msgid "You do not have permission to create %(class)s"
+msgstr "Vous n'avez pas la permission de créer de %(class)s"
+
+#: ../roundup/cgi/actions.py:487
+msgid "You do not have permission to edit user roles"
+msgstr "Vous n'avez pas la permission de modifier les rôles d'un utilisateur"
+
+#: ../roundup/cgi/actions.py:537
+#, python-format
+msgid ""
+"Edit Error: someone else has edited this %s (%s). View <a target=\"new\" href="
+"\"%s%s\">their changes</a> in a new window."
+msgstr ""
+"Erreur de modification: quelqu'un d'autre a édité ce %s (%s). Voir <a target="
+"\"new\" href=\"%s%s\">ses modifications</a> dans une nouvelle fenêtre."
+
+#: ../roundup/cgi/actions.py:565
+#, python-format
+msgid "Edit Error: %s"
+msgstr "Erreur de modification: %s"
+
+# ../roundup/cgi/actions.py:596 :607 :778 :797
+#: ../roundup/cgi/actions.py:596 ../roundup/cgi/actions.py:607
+#: ../roundup/cgi/actions.py:778 ../roundup/cgi/actions.py:797
+#, python-format
+msgid "Error: %s"
+msgstr "Erreur: %s"
+
+#: ../roundup/cgi/actions.py:633
+msgid ""
+"Invalid One Time Key!\n"
+"(a Mozilla bug may cause this message to show up erroneously, please check "
+"your email)"
+msgstr ""
+"La clé à usage unique est invalide !\n"
+"(un bug dans Mozilla peut provoquer une apparition erronée de ce message, "
+"veuillez vérifier votre courriel)"
+
+#: ../roundup/cgi/actions.py:675
+#, python-format
+msgid "Password reset and email sent to %s"
+msgstr "Mot de passe réinitialisé et courriel envoyé à %s"
+
+#: ../roundup/cgi/actions.py:684
+msgid "Unknown username"
+msgstr "Nom d'utilisateur inconnu"
+
+#: ../roundup/cgi/actions.py:692
+msgid "Unknown email address"
+msgstr "Adresse courriel inconnue"
+
+#: ../roundup/cgi/actions.py:697
+msgid "You need to specify a username or address"
+msgstr "Vous devez spécifier un nom d'utilisateur ou une adresse courriel"
+
+#: ../roundup/cgi/actions.py:722
+#, python-format
+msgid "Email sent to %s"
+msgstr "Courriel envoyé à %s"
+
+#: ../roundup/cgi/actions.py:741
+msgid "You are now registered, welcome!"
+msgstr "Vous êtes désormais inscrit, bienvenue !"
+
+#: ../roundup/cgi/actions.py:786
+msgid "It is not permitted to supply roles at registration."
+msgstr ""
+
+#: ../roundup/cgi/actions.py:878
+msgid "You are logged out"
+msgstr "Vous êtes déconnecté"
+
+#: ../roundup/cgi/actions.py:895
+msgid "Username required"
+msgstr "Nom d'utilisateur requis"
+
+# ../roundup/cgi/actions.py:930 :934
+#: ../roundup/cgi/actions.py:930 ../roundup/cgi/actions.py:934
+msgid "Invalid login"
+msgstr "Tentative de connexion invalide"
+
+#: ../roundup/cgi/actions.py:940
+msgid "You do not have permission to login"
+msgstr "Vous n'avez la permission de vous connecter"
+
+#: ../roundup/cgi/cgitb.py:49
+#, python-format
+msgid ""
+"<h1>Templating Error</h1>\n"
+"<p><b>%(exc_type)s</b>: %(exc_value)s</p>\n"
+"<p class=\"help\">Debugging information follows</p>"
+msgstr ""
+"<h1>Erreur de modèle</h1>\n"
+"<p><b>%(exc_type)s</b>: %(exc_value)s</p>\n"
+"<p class=\"help\">Les informations de déboguage suivent</p>"
+
+#: ../roundup/cgi/cgitb.py:64
+#, python-format
+msgid "<li>\"%(name)s\" (%(info)s)</li>"
+msgstr "<li>\"%(name)s\" (%(info)s)</li>"
+
+#: ../roundup/cgi/cgitb.py:67
+#, python-format
+msgid "<li>Looking for \"%(name)s\", current path:<ol>%(path)s</ol></li>"
+msgstr "<li>Recherche de \"%(name)s\", chemin actuel:<ol>%(path)s</ol></li>"
+
+#: ../roundup/cgi/cgitb.py:71
+#, python-format
+msgid "<li>In %s</li>"
+msgstr "<li>Dans %s</li>"
+
+#: ../roundup/cgi/cgitb.py:76
+#, python-format
+msgid "A problem occurred in your template \"%s\"."
+msgstr "Un problème est apparu dans votre modèle \"%s\"."
+
+#: ../roundup/cgi/cgitb.py:84
+#, python-format
+msgid ""
+"\n"
+"<li>While evaluating the %(info)r expression on line %(line)d\n"
+"<table class=\"otherinfo\" style=\"font-size: 90%%\">\n"
+" <tr><th colspan=\"2\" class=\"header\">Current variables:</th></tr>\n"
+" %(globals)s\n"
+" %(locals)s\n"
+"</table></li>\n"
+msgstr ""
+"\n"
+"<li>Lors de l'évaluation de l'expression %(info)r à la ligne %(line)d\n"
+"<table class=\"otherinfo\" style=\"font-size: 90%%\">\n"
+" <tr><th colspan=\"2\" class=\"header\">Variables courantes:</th></tr>\n"
+" %(globals)s\n"
+" %(locals)s\n"
+"</table></li>\n"
+
+#: ../roundup/cgi/cgitb.py:103
+msgid "Full traceback:"
+msgstr "Historique complet:"
+
+#: ../roundup/cgi/cgitb.py:116
+#, python-format
+msgid "<font size=+1><strong>%(exc_type)s</strong>: %(exc_value)s</font>"
+msgstr "<font size=+1><strong>%(exc_type)s</strong>: %(exc_value)s</font>"
+
+#: ../roundup/cgi/cgitb.py:120
+msgid ""
+"<p>A problem occurred while running a Python script. Here is the sequence of "
+"function calls leading up to the error, with the most recent (innermost) call "
+"first. The exception attributes are:"
+msgstr ""
+"<p>Un problème est apparu lors de l'exécution d'un script Python. Voici la "
+"suite d'appels de fonction menant à l'erreur, avec l'appel le plus récent "
+"(le plus imbriqué) d'abord. Les attributs de l'exception sont:"
+
+#: ../roundup/cgi/cgitb.py:129
+msgid "&lt;file is None - probably inside <tt>eval</tt> or <tt>exec</tt>&gt;"
+msgstr ""
+"&lt;\"file\" est à \"None\" - probablement dans un <tt>eval</tt> ou un "
+"<tt>exec</tt>&gt;"
+
+#: ../roundup/cgi/cgitb.py:138
+#, python-format
+msgid "in <strong>%s</strong>"
+msgstr "dans <strong>%s</strong>"
+
+# ../roundup/cgi/cgitb.py:172 :178
+#: ../roundup/cgi/cgitb.py:172 ../roundup/cgi/cgitb.py:178
+msgid "<em>undefined</em>"
+msgstr "<em>non défini</em>"
+
+#: ../roundup/cgi/client.py:49
+msgid ""
+"<html><head><title>An error has occurred</title></head>\n"
+"<body><h1>An error has occurred</h1>\n"
+"<p>A problem was encountered processing your request.\n"
+"The tracker maintainers have been notified of the problem.</p>\n"
+"</body></html>"
+msgstr ""
+"<html><head><title>Une erreur s'est produite</title></head>\n"
+"<body><h1>Une erreur s'est produite</h1>\n"
+"<p>Un problème a été rencontré lors du traitement de votre requête.\n"
+"Les administrateurs du pisteur ont été notifiés du problème.</p>\n"
+"</body></html>"
+
+#: ../roundup/cgi/client.py:308
+msgid "Form Error: "
+msgstr "Erreur de formulaire: "
+
+#: ../roundup/cgi/client.py:363
+#, python-format
+msgid "Unrecognized charset: %r"
+msgstr "Table de caractères non reconnue: %r"
+
+#: ../roundup/cgi/client.py:490
+msgid "Anonymous users are not allowed to use the web interface"
+msgstr ""
+"Les utilisateurs anonymes ne sont pas autorisés à utiliser l'interface Web"
+
+#: ../roundup/cgi/client.py:645
+msgid "You are not allowed to view this file."
+msgstr "Vous n'êtes pas autorisé à voir ce fichier"
+
+#: ../roundup/cgi/client.py:737
+#, python-format
+msgid "%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n"
+msgstr "%(starttag)sTemps écoulé: %(seconds)fs%(endtag)s\n"
+
+#: ../roundup/cgi/client.py:741
+#, python-format
+msgid ""
+"%(starttag)sCache hits: %(cache_hits)d, misses %(cache_misses)d. Loading "
+"items: %(get_items)f secs. Filtering: %(filtering)f secs.%(endtag)s\n"
+msgstr ""
+"%(starttag)sAccès au cache: %(cache_hits)d, ratés %(cache_misses)d. "
+"Chargement d'éléments: %(get_items)f secondes. Filtrage: %(filtering)f "
+"secondes.%(endtag)s\n"
+
+#: ../roundup/cgi/form_parser.py:283
+#, python-format
+msgid "link \"%(key)s\" value \"%(value)s\" not a designator"
+msgstr "la valeur \"%(value)s\" du lien \"%(key)s\" n'est pas un indicateur"
+
+#: ../roundup/cgi/form_parser.py:290
+#, python-format
+msgid "%(class)s %(property)s is not a link or multilink property"
+msgstr "%(class)s %(property)s n'est pas une propriété lien ou lien multiple"
+
+#: ../roundup/cgi/form_parser.py:312
+#, python-format
+msgid ""
+"You have submitted a %(action)s action for the property \"%(property)s\" "
+"which doesn't exist"
+msgstr ""
+"Vous avez demandé une action \"%(action)s\" sur une propriété \"%(property)s"
+"\" qui n'existe pas"
+
+# ../roundup/cgi/form_parser.py:331 :357
+#: ../roundup/cgi/form_parser.py:331 ../roundup/cgi/form_parser.py:357
+#, python-format
+msgid "You have submitted more than one value for the %s property"
+msgstr "Vous avez fourni plus d'une valeur pour la propriété %s"
+
+# ../roundup/cgi/form_parser.py:354 :360
+#: ../roundup/cgi/form_parser.py:354 ../roundup/cgi/form_parser.py:360
+msgid "Password and confirmation text do not match"
+msgstr "Le mot de passe et le texte de confirmation ne correspondent pas"
+
+#: ../roundup/cgi/form_parser.py:395
+#, python-format
+msgid "property \"%(propname)s\": \"%(value)s\" not currently in list"
+msgstr ""
+"propriété \"%(propname)s\": \"%(value)s\" n'est pas actuellement dans la "
+"liste"
+
+#: ../roundup/cgi/form_parser.py:512
+#, python-format
+msgid "Required %(class)s property %(property)s not supplied"
+msgid_plural "Required %(class)s properties %(property)s not supplied"
+msgstr[0] "La propriété requise %(property)s de %(class)s n'a pas été fournie"
+msgstr[1] ""
+"Les propriétés requises %(property)s de %(class)s n'ont pas été fournies"
+
+#: ../roundup/cgi/form_parser.py:535
+msgid "File is empty"
+msgstr "Le fichier est vide"
+
+#: ../roundup/cgi/templating.py:72
+#, python-format
+msgid "You are not allowed to %(action)s items of class %(class)s"
+msgstr "Vous ne pouvez pas %(action)s des éléments de classe %(class)s"
+
+#: ../roundup/cgi/templating.py:627
+msgid "(list)"
+msgstr "(liste)"
+
+#: ../roundup/cgi/templating.py:696
+msgid "Submit New Entry"
+msgstr "Soumettre un nouvelle entrée"
+
+# ../roundup/cgi/templating.py:710 :829 :1236 :1257 :1304 :1327 :1361 :1400
+# :1453 :1470 :1549 :1569 :1587 :1619 :1629 :1683 :1875
+#: ../roundup/cgi/templating.py:710 ../roundup/cgi/templating.py:829
+#: ../roundup/cgi/templating.py:1236 ../roundup/cgi/templating.py:1257
+#: ../roundup/cgi/templating.py:1304 ../roundup/cgi/templating.py:1327
+#: ../roundup/cgi/templating.py:1361 ../roundup/cgi/templating.py:1400
+#: ../roundup/cgi/templating.py:1453 ../roundup/cgi/templating.py:1470
+#: ../roundup/cgi/templating.py:1549 ../roundup/cgi/templating.py:1569
+#: ../roundup/cgi/templating.py:1587 ../roundup/cgi/templating.py:1619
+#: ../roundup/cgi/templating.py:1629 ../roundup/cgi/templating.py:1683
+#: ../roundup/cgi/templating.py:1875
+msgid "[hidden]"
+msgstr "[masqué]"
+
+#: ../roundup/cgi/templating.py:711
+msgid "New node - no history"
+msgstr "Nouveau noeud - pas d'historique"
+
+#: ../roundup/cgi/templating.py:811
+msgid "Submit Changes"
+msgstr "Soumettre les changements"
+
+#: ../roundup/cgi/templating.py:893
+msgid "<em>The indicated property no longer exists</em>"
+msgstr "<em>La propriété indiquée n'existe plus</em>"
+
+#: ../roundup/cgi/templating.py:894
+#, python-format
+msgid "<em>%s: %s</em>\n"
+msgstr "<em>%s: %s</em>\n"
+
+#: ../roundup/cgi/templating.py:907
+#, python-format
+msgid "The linked class %(classname)s no longer exists"
+msgstr "La classe liée %(classname)s n'existe plus"
+
+# ../roundup/cgi/templating.py:940 :964
+#: ../roundup/cgi/templating.py:940 ../roundup/cgi/templating.py:964
+msgid "<strike>The linked node no longer exists</strike>"
+msgstr "<strike>Le noeud lié n'existe plus</strike>"
+
+# ../roundup/cgi/templating.py:1006 :1404 :1425 :1431
+#: ../roundup/cgi/templating.py:1006 ../roundup/cgi/templating.py:1404
+#: ../roundup/cgi/templating.py:1425 ../roundup/cgi/templating.py:1431
+msgid "No"
+msgstr "Non"
+
+# ../roundup/cgi/templating.py:1006 :1404 :1423 :1428
+#: ../roundup/cgi/templating.py:1006 ../roundup/cgi/templating.py:1404
+#: ../roundup/cgi/templating.py:1423 ../roundup/cgi/templating.py:1428
+msgid "Yes"
+msgstr "Oui"
+
+#: ../roundup/cgi/templating.py:1017
+#, python-format
+msgid "%s: (no value)"
+msgstr "%s: (pas de valeur)"
+
+#: ../roundup/cgi/templating.py:1029
+msgid ""
+"<strong><em>This event is not handled by the history display!</em></strong>"
+msgstr ""
+"<strong><em>Cet évènement n'est pas géré par l'affichage de l'historique !</"
+"em></strong>"
+
+#: ../roundup/cgi/templating.py:1041
+msgid "<tr><td colspan=4><strong>Note:</strong></td></tr>"
+msgstr "<tr><td colspan=4><strong>Note:</strong></td></tr>"
+
+#: ../roundup/cgi/templating.py:1050
+msgid "History"
+msgstr "Historique"
+
+#: ../roundup/cgi/templating.py:1052
+msgid "<th>Date</th>"
+msgstr "<th>Date</th>"
+
+#: ../roundup/cgi/templating.py:1053
+msgid "<th>User</th>"
+msgstr "<th>Utilisateur</th>"
+
+#: ../roundup/cgi/templating.py:1054
+msgid "<th>Action</th>"
+msgstr "<th>Action</th>"
+
+#: ../roundup/cgi/templating.py:1055
+msgid "<th>Args</th>"
+msgstr "<th>Arguments</th>"
+
+#: ../roundup/cgi/templating.py:1097
+#, python-format
+msgid "Copy of %(class)s %(id)s"
+msgstr "Copie de %(class)s %(id)s"
+
+#: ../roundup/cgi/templating.py:1331
+msgid "*encrypted*"
+msgstr "*encrypté*"
+
+#: ../roundup/cgi/templating.py:1514
+msgid ""
+"default value for DateHTMLProperty must be either DateHTMLProperty or string "
+"date representation."
+msgstr ""
+"la valeur par défaut pour DateHTMLProperty doit être soit DateHTMLProperty "
+"soit une représentation textuelle de la date."
+
+#: ../roundup/cgi/templating.py:1674
+#, python-format
+msgid "Attempt to look up %(attr)s on a missing value"
+msgstr "Tentative de recherche de %(attr)s sur une valeur manquante"
+
+#: ../roundup/cgi/templating.py:1750
+#, python-format
+msgid "<option %svalue=\"-1\">- no selection -</option>"
+msgstr "<option %svalue=\"-1\">- pas de sélection -</option>"
+
+#: ../roundup/date.py:186
+msgid ""
+"Not a date spec: \"yyyy-mm-dd\", \"mm-dd\", \"HH:MM\", \"HH:MM:SS\" or \"yyyy-"
+"mm-dd.HH:MM:SS.SSS\""
+msgstr ""
+"Ceci n'est pas une représentation de date: \"aaaa-mm-jj\", \"mm-jj\", \"HH:MM"
+"\", \"HH:MM:SS\" or \"aaaa-mm-jj.HH:MM:SS.SSS\""
+
+#: ../roundup/date.py:240
+#, python-format
+msgid ""
+"%r not a date / time spec \"yyyy-mm-dd\", \"mm-dd\", \"HH:MM\", \"HH:MM:SS\" "
+"or \"yyyy-mm-dd.HH:MM:SS.SSS\""
+msgstr ""
+"%r n'est pas une représentation de date ou d'heure \"aaaa-mm-jj\", \"mm-jj"
+"\", \"HH:MM\", \"HH:MM:SS\" or \"aaaa-mm-jj.HH:MM:SS.SSS\""
+
+#: ../roundup/date.py:538
+msgid ""
+"Not an interval spec: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS] [date spec]"
+msgstr ""
+"Ceci n'est pas une représentation d'intervalle: [+-] [#a] [#m] [#s] [#j] "
+"[[[H]H:MM]:SS] [représentation de date]"
+
+#: ../roundup/date.py:557
+msgid "Not an interval spec: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS]"
+msgstr ""
+"Ceci n'est pas une représentation d'intervalle: [+-] [#a] [#m] [#s] [#j] "
+"[[[H]H:MM]:SS]"
+
+#: ../roundup/date.py:694
+#, python-format
+msgid "%(number)s year"
+msgid_plural "%(number)s years"
+msgstr[0] "%(number)s année"
+msgstr[1] "%(number)s années"
+
+#: ../roundup/date.py:698
+#, python-format
+msgid "%(number)s month"
+msgid_plural "%(number)s months"
+msgstr[0] "%(number)s mois"
+msgstr[1] "%(number)s mois"
+
+#: ../roundup/date.py:702
+#, python-format
+msgid "%(number)s week"
+msgid_plural "%(number)s weeks"
+msgstr[0] "%(number)s semaine"
+msgstr[1] "%(number)s semaines"
+
+#: ../roundup/date.py:706
+#, python-format
+msgid "%(number)s day"
+msgid_plural "%(number)s days"
+msgstr[0] "%(number)s jour"
+msgstr[1] "%(number)s jours"
+
+#: ../roundup/date.py:710
+msgid "tomorrow"
+msgstr "demain"
+
+#: ../roundup/date.py:712
+msgid "yesterday"
+msgstr "hier"
+
+#: ../roundup/date.py:715
+#, python-format
+msgid "%(number)s hour"
+msgid_plural "%(number)s hours"
+msgstr[0] "%(number)s heure"
+msgstr[1] "%(number)s heures"
+
+#: ../roundup/date.py:719
+msgid "an hour"
+msgstr "une heure"
+
+#: ../roundup/date.py:721
+msgid "1 1/2 hours"
+msgstr "1 heure et demie"
+
+#: ../roundup/date.py:723
+#, python-format
+msgid "1 %(number)s/4 hours"
+msgid_plural "1 %(number)s/4 hours"
+msgstr[0] "1 heure et quart"
+msgstr[1] "1 heure trois-quart"
+
+#: ../roundup/date.py:727
+msgid "in a moment"
+msgstr "dans un instant"
+
+#: ../roundup/date.py:729
+msgid "just now"
+msgstr "à l'instant"
+
+#: ../roundup/date.py:732
+msgid "1 minute"
+msgstr "une minute"
+
+#: ../roundup/date.py:735
+#, python-format
+msgid "%(number)s minute"
+msgid_plural "%(number)s minutes"
+msgstr[0] "%(number)s minute"
+msgstr[1] "%(number)s minutes"
+
+#: ../roundup/date.py:738
+msgid "1/2 an hour"
+msgstr "une demi-heure"
+
+#: ../roundup/date.py:740
+#, python-format
+msgid "%(number)s/4 hour"
+msgid_plural "%(number)s/4 hours"
+msgstr[0] "un quart d'heure"
+msgstr[1] "trois quarts d'heure"
+
+#: ../roundup/date.py:744
+#, python-format
+msgid "%s ago"
+msgstr "il y a %s"
+
+#: ../roundup/date.py:746
+#, python-format
+msgid "in %s"
+msgstr "dans %s"
+
+#: ../roundup/init.py:134
+#, python-format
+msgid ""
+"WARNING: directory '%s'\n"
+"\tcontains old-style template - ignored"
+msgstr ""
+"ATTENTION: le répertoire '%s'\n"
+"\tcontient des modèles obsolètes - ignoré"
+
+#: ../roundup/roundupdb.py:141
+msgid "files"
+msgstr "fichiers"
+
+#: ../roundup/roundupdb.py:141
+msgid "messages"
+msgstr "messages"
+
+#: ../roundup/roundupdb.py:141
+msgid "nosy"
+msgstr "curieux"
+
+#: ../roundup/roundupdb.py:141
+msgid "superseder"
+msgstr "remplaçant"
+
+#: ../roundup/roundupdb.py:141
+msgid "title"
+msgstr "titre"
+
+#: ../roundup/roundupdb.py:142
+msgid "assignedto"
+msgstr "assigné_à"
+
+#: ../roundup/roundupdb.py:142
+msgid "priority"
+msgstr "priorité"
+
+#: ../roundup/roundupdb.py:142
+msgid "status"
+msgstr "état"
+
+#: ../roundup/roundupdb.py:142
+msgid "topic"
+msgstr "sujet"
+
+#: ../roundup/roundupdb.py:145
+msgid "activity"
+msgstr "activité"
+
+#. following properties are common for all hyperdb classes
+#. they are listed here to keep things in one place
+#: ../roundup/roundupdb.py:145
+msgid "actor"
+msgstr "acteur"
+
+#: ../roundup/roundupdb.py:145
+msgid "creation"
+msgstr "création"
+
+#: ../roundup/roundupdb.py:145
+msgid "creator"
+msgstr "créateur"
+
+#: ../roundup/scripts/roundup_demo.py:32
+#, python-format
+msgid "Enter directory path to create demo tracker [%s]: "
+msgstr ""
+"Saisissez le chemin du répertoire dans lequel créer le pisteur de "
+"démonstration [%s]: "
+
+#: ../roundup/scripts/roundup_gettext.py:22
+#, python-format
+msgid "Usage: %(program)s <tracker home>"
+msgstr "Usage: %(program)s <répertoire du pisteur>"
+
+#: ../roundup/scripts/roundup_gettext.py:37
+#, python-format
+msgid "No tracker templates found in directory %s"
+msgstr "Aucun modèle de pisteur dans le répertoire %s"
+
+#: ../roundup/scripts/roundup_mailgw.py:36
+#, python-format
+msgid ""
+"Usage: %(program)s [-v] [-c] [[-C class] -S field=value]* <instance home> "
+"[method]\n"
+"\n"
+"Options:\n"
+" -v: print version and exit\n"
+" -c: default class of item to create (else the tracker's MAIL_DEFAULT_CLASS)\n"
+" -C / -S: see below\n"
+"\n"
+"The roundup mail gateway may be called in one of four ways:\n"
+" . with an instance home as the only argument,\n"
+" . with both an instance home and a mail spool file,\n"
+" . with both an instance home and a POP/APOP server account, or\n"
+" . with both an instance home and a IMAP/IMAPS server account.\n"
+"\n"
+"It also supports optional -C and -S arguments that allows you to set a\n"
+"fields for a class created by the roundup-mailgw. The default class if\n"
+"not specified is msg, but the other classes: issue, file, user can\n"
+"also be used. The -S or --set options uses the same\n"
+"property=value[;property=value] notation accepted by the command line\n"
+"roundup command or the commands that can be given on the Subject line\n"
+"of an email message.\n"
+"\n"
+"It can let you set the type of the message on a per email address basis.\n"
+"\n"
+"PIPE:\n"
+" In the first case, the mail gateway reads a single message from the\n"
+" standard input and submits the message to the roundup.mailgw module.\n"
+"\n"
+"UNIX mailbox:\n"
+" In the second case, the gateway reads all messages from the mail spool\n"
+" file and submits each in turn to the roundup.mailgw module. The file is\n"
+" emptied once all messages have been successfully handled. The file is\n"
+" specified as:\n"
+"   mailbox /path/to/mailbox\n"
+"\n"
+"POP:\n"
+" In the third case, the gateway reads all messages from the POP server\n"
+" specified and submits each in turn to the roundup.mailgw module. The\n"
+" server is specified as:\n"
+"    pop username:password at server\n"
+" The username and password may be omitted:\n"
+"    pop username at server\n"
+"    pop server\n"
+" are both valid. The username and/or password will be prompted for if\n"
+" not supplied on the command-line.\n"
+"\n"
+"APOP:\n"
+" Same as POP, but using Authenticated POP:\n"
+"    apop username:password at server\n"
+"\n"
+"IMAP:\n"
+" Connect to an IMAP server. This supports the same notation as that of\n"
+" POP mail.\n"
+"    imap username:password at server\n"
+" It also allows you to specify a specific mailbox other than INBOX using\n"
+" this format:\n"
+"    imap username:password at server mailbox\n"
+"\n"
+"IMAPS:\n"
+" Connect to an IMAP server over ssl.\n"
+" This supports the same notation as IMAP.\n"
+"    imaps username:password at server [mailbox]\n"
+"\n"
+msgstr ""
+"Usage: %(program)s [-v] [[-C classe] -S champ=valeur]* <base de l'instance> "
+"[méthode]\n"
+"\n"
+"Options:\n"
+" -v: imprime la version et quitte\n"
+" -c: classe de l'élement à créer (par défaut, la classe MAIL_DEFAULT_CLASS)\n"
+" -C / -S: voir ci-après\n"
+"\n"
+"La passerelle de messagerie de Roundup peut être appelée de quatre "
+"manières:\n"
+" . avec le répertoire de base d'une instance comme seul argument,\n"
+" . avec à la fois un répertoire de base et un fichier d'attente de "
+"messagerie,\n"
+" . avec à la fois un répertoire de base et un compte de serveur POP/APOP, "
+"ou\n"
+" . avec à la fois un répertoire de base et un compte de serveur IMAP/IMAPS.\n"
+"\n"
+"Elle accepte également les options -C et -S qui vous permettent d'assigner\n"
+"des champs pour une classe créée par roundup-mailgw. La classe par défaut, "
+"si\n"
+"elle n'est pas spécifiée, est \"msg\", mais les autres classes: \"issue\",\n"
+"\"file\", \"user\" peuvent également être utilisées. Les options -S ou --"
+"set \n"
+"utilisent la même notation propriété=valeur[;propriété=valeur] acceptée par "
+"la\n"
+"ligne de commande de  Roundup ou par les commandes qui peuvent être données\n"
+"dans l'objet d'un courriel.\n"
+"\n"
+"Elle vous permet également de spécifier le type de message pour chaque "
+"adresse\n"
+"de messagerie.\n"
+"\n"
+"PIPE:\n"
+" Dans le premier cas, la passerelle de messagerie lit un seul message "
+"venant\n"
+" de l'entrée standard et le soumet au module roundup.mailgw.\n"
+"\n"
+"UNIX mailbox:\n"
+" Dans le second cas, la passerelle lit tout les messages venant du fichier\n"
+" d'attente de messagerie et les soumet chacun à leur tour au module\n"
+" roundup.mailgw. Le fichier est vidé une fois que tout les messages ont été\n"
+" traités avec succés. Le fichier est spécifié comme:\n"
+"   mailbox /chemin/vers/mailbox\n"
+"\n"
+"POP:\n"
+" Dans le troisième cas, la passerelle lit tout les messages du serveur POP\n"
+" spécifié et les soumet chacun à leur tour au module roundup.mailgw. Le\n"
+" serveur est spécifié comme suit:\n"
+"    pop nom-d'utilisateur:mot-de-passe at serveur\n"
+" Le nom d'utilisateur et le mot de passe peuvent être omis:\n"
+"    pop nom-d'utilisateur at serveur\n"
+"    pop server\n"
+" sont tous deux valides. Le nom d'utilisateur et/ou le mot de passe seront\n"
+" demandés s'ils ne sont pas fournis dans la ligne de commande.\n"
+"\n"
+"APOP:\n"
+" Identique à POP, mais utilisant le POP authentifié:\n"
+"    apop nom-d'utilisateur:mot-de-passe at serveur\n"
+"\n"
+"IMAP:\n"
+" Se connecte à un serveur IMAP. Supporte la même notation que pour la\n"
+" messagerie POP\n"
+"    imap nom-d'utilisateur:mot-de-passe at serveur\n"
+" Vous permet également de spécifier une boîte aux lettres spécifique, autre\n"
+" que INBOX, en utilisant ce format:\n"
+"    imap nom-d'utilisateur:mot-de-passe at serveur boîte-aux-lettres\n"
+"\n"
+"IMAPS:\n"
+" Se connecte avec SSL à un serveur IMAP.\n"
+" Supporte la même notation que IMAP.\n"
+"    imaps nom-d'utilisateur:mot-de-passe at serveur [boîte-aux-lettres]\n"
+"\n"
+
+#: ../roundup/scripts/roundup_mailgw.py:147
+msgid "Error: not enough source specification information"
+msgstr "Erreur: pas assez d'informations dans la spécification de la source"
+
+#: ../roundup/scripts/roundup_mailgw.py:163
+msgid "Error: pop specification not valid"
+msgstr "Erreur: la spécification pop n'est pas valide"
+
+#: ../roundup/scripts/roundup_mailgw.py:170
+msgid "Error: apop specification not valid"
+msgstr "Erreur: la spécification apop n'est pas valide"
+
+#: ../roundup/scripts/roundup_mailgw.py:184
+msgid ""
+"Error: The source must be either \"mailbox\", \"pop\", \"apop\", \"imap\" or "
+"\"imaps\""
+msgstr ""
+"Erreur: la source doit être \"mailbox\", \"pop\", \"apop\", \"imap\" ou "
+"\"imaps\""
+
+#: ../roundup/scripts/roundup_server.py:157
+msgid ""
+"<html><head><title>Roundup trackers index</title></head>\n"
+"<body><h1>Roundup trackers index</h1><ol>\n"
+msgstr ""
+"<html><head><title>Index des pisteurs Roundup</title></head>\n"
+"<body><h1>Index des pisteurs Roundup</h1><ol>\n"
+
+#: ../roundup/scripts/roundup_server.py:287
+#, python-format
+msgid "Error: %s: %s"
+msgstr "Erreur: %s: %s"
+
+#: ../roundup/scripts/roundup_server.py:297
+msgid "WARNING: ignoring \"-g\" argument, not root"
+msgstr ""
+"ATTENTION: le paramètre \"-g\" est ignoré, vous n'êtes pas superutilisateur "
+"(\"root\")"
+
+#: ../roundup/scripts/roundup_server.py:303
+msgid "Can't change groups - no grp module"
+msgstr "Impossible de changer les groupes - le module grp n'est pas présent"
+
+#: ../roundup/scripts/roundup_server.py:312
+#, python-format
+msgid "Group %(group)s doesn't exist"
+msgstr "Le groupe %(group)s n'existe pas"
+
+#: ../roundup/scripts/roundup_server.py:323
+msgid "Can't run as root!"
+msgstr "Impossible d'exécuter en tant que superutilisateur (\"root\")"
+
+#: ../roundup/scripts/roundup_server.py:326
+msgid "WARNING: ignoring \"-u\" argument, not root"
+msgstr ""
+"ATTENTION: le paramètre \"-u\" est ignoré, vous n'êtes pas superutilisateur "
+"(\"root\")"
+
+#: ../roundup/scripts/roundup_server.py:331
+msgid "Can't change users - no pwd module"
+msgstr ""
+"Impossible de changer les utilisateurs - le module pwd n'est pas présent"
+
+#: ../roundup/scripts/roundup_server.py:340
+#, python-format
+msgid "User %(user)s doesn't exist"
+msgstr "L'utilisateur %(user)s n'existe pas"
+
+#: ../roundup/scripts/roundup_server.py:471
+#, python-format
+msgid "Multiprocess mode \"%s\" is not available, switching to single-process"
+msgstr ""
+"Le mode multiprocessus \"%s\" n'existe pas, passage en mode processus unique"
+
+#: ../roundup/scripts/roundup_server.py:494
+#, python-format
+msgid "Unable to bind to port %s, port already in use."
+msgstr "Impossible de s'attacher au port %s, le port est déjà utilisé"
+
+#: ../roundup/scripts/roundup_server.py:562
+msgid ""
+" -c <Command>  Windows Service options.\n"
+"               If you want to run the server as a Windows Service, you\n"
+"               must use configuration file to specify tracker homes.\n"
+"               Logfile option is required to run Roundup Tracker service.\n"
+"               Typing \"roundup-server -c help\" shows Windows Services\n"
+"               specifics."
+msgstr ""
+" -c <Commande> \n"
+"               Options des services Windows.\n"
+"               Si vous désirez démarrer le serveur comme service Windows,\n"
+"               vous devez utiliser le fichier de configuration pour\n"
+"               préciser les répertoires des pisteurs.\n"
+"               L'option Logfile est requise pour exécuter le service\n"
+"               RoundUp Tracker.\n"
+"               La commande \"roundup-server -c help\" donne les "
+"               spécificités du service Windows."
+
+#: ../roundup/scripts/roundup_server.py:569
+msgid ""
+" -u <UID>      runs the Roundup web server as this UID\n"
+" -g <GID>      runs the Roundup web server as this GID\n"
+" -d <PIDfile>  run the server in the background and write the server's PID\n"
+"               to the file indicated by PIDfile. The -l option *must* be\n"
+"               specified if -d is used."
+msgstr ""
+" -u <UID>      démarre le serveur Web de Roundup sous l'identificateur\n"
+"               d'utilisateur UID\"\n"
+" -g <GID>      démarre le serveur Web de Roundup sous l'identificateur\n"
+"               de groupe GID\n"
+" -d <fichier-PID>\n"
+"               démarre le serveur en tâche de fond et écrit "
+"l'identificateur\n"
+"               de processus (\"PID\") dans le fichier spécifié par fichier-"
+"PID\n"
+"               L'option -l option *doit* être spécifiée si -d est utilisé."
+
+#: ../roundup/scripts/roundup_server.py:576
+#, python-format
+msgid ""
+"%(message)sUsage: roundup-server [options] [name=tracker home]*\n"
+"\n"
+"Options:\n"
+" -v            print the Roundup version number and exit\n"
+" -h            print this text and exit\n"
+" -S            create or update configuration file and exit\n"
+" -C <fname>    use configuration file <fname>\n"
+" -n <name>     set the host name of the Roundup web server instance\n"
+" -p <port>     set the port to listen on (default: %(port)s)\n"
+" -l <fname>    log to the file indicated by fname instead of stderr/stdout\n"
+" -N            log client machine names instead of IP addresses (much "
+"slower)\n"
+" -t <mode>     multiprocess mode (default: %(mp_def)s).\n"
+"               Allowed values: %(mp_types)s.\n"
+"%(os_part)s\n"
+"\n"
+"Long options:\n"
+" --version          print the Roundup version number and exit\n"
+" --help             print this text and exit\n"
+" --save-config      create or update configuration file and exit\n"
+" --config <fname>   use configuration file <fname>\n"
+" All settings of the [main] section of the configuration file\n"
+" also may be specified in form --<name>=<value>\n"
+"\n"
+"Examples:\n"
+"\n"
+" roundup-server -S -C /opt/roundup/etc/roundup-server.ini \\\n"
+"    -n localhost -p 8917 -l /var/log/roundup.log \\\n"
+"    support=/var/spool/roundup-trackers/support\n"
+"\n"
+" roundup-server -C /opt/roundup/etc/roundup-server.ini\n"
+"\n"
+" roundup-server support=/var/spool/roundup-trackers/support\n"
+"\n"
+" roundup-server -d /var/run/roundup.pid -l /var/log/roundup.log \\\n"
+"    support=/var/spool/roundup-trackers/support\n"
+"\n"
+"Configuration file format:\n"
+"   Roundup Server configuration file has common .ini file format.\n"
+"   Configuration file created with 'roundup-server -S' contains\n"
+"   detailed explanations for each option.  Please see that file\n"
+"   for option descriptions.\n"
+"\n"
+"How to use \"name=tracker home\":\n"
+"   These arguments set the tracker home(s) to use. The name is how the\n"
+"   tracker is identified in the URL (it's the first part of the URL path).\n"
+"   The tracker home is the directory that was identified when you did\n"
+"   \"roundup-admin init\". You may specify any number of these name=home\n"
+"   pairs on the command-line. Make sure the name part doesn't include\n"
+"   any url-unsafe characters like spaces, as these confuse IE.\n"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:724
+msgid "Instances must be name=home"
+msgstr "Les instances doivent être nom=base-du-pisteur"
+
+#: ../roundup/scripts/roundup_server.py:738
+#, python-format
+msgid "Configuration saved to %s"
+msgstr "Configuration sauvegardée dans %s"
+
+#: ../roundup/scripts/roundup_server.py:756
+msgid "Sorry, you can't run the server as a daemon on this Operating System"
+msgstr ""
+"Désolé, vous ne pouvez pas démarrer le serveur en tâche de fond avec ce "
+"système d'exploitation"
+
+#: ../roundup/scripts/roundup_server.py:768
+#, python-format
+msgid "Roundup server started on %(HOST)s:%(PORT)s"
+msgstr "Le serveur Roundup est démarré sur %(HOST)s:%(PORT)s"
+
+#: ../templates/classic/html/_generic.collision.html:4
+#: ../templates/minimal/html/_generic.collision.html:4
+msgid "${class} Edit Collision - ${tracker}"
+msgstr "&Eacute;dition des collisions pour ${class} - ${tracker}"
+
+#: ../templates/classic/html/_generic.collision.html:7
+#: ../templates/minimal/html/_generic.collision.html:7
+msgid "${class} Edit Collision"
+msgstr "&Eacute;dition des collisions pour ${class}"
+
+#: ../templates/classic/html/_generic.collision.html:14
+#: ../templates/minimal/html/_generic.collision.html:14
+msgid ""
+"\n"
+"  There has been a collision. Another user updated this node\n"
+"  while you were editing. Please <a href='${context}'>reload</a>\n"
+"  the node and review your edits.\n"
+msgstr ""
+"\n"
+"  Une collision s'est produite. Un autre utilisateur a mis &agrave; jour ce\n"
+"  noeud pendant que vous l'&eacute;ditiez. Veuillez <a "
+"href='${context}'>recharger</a>\n"
+"  ce noeud et v&eacute;rifier vos modifications.\n"
+
+#: ../templates/classic/html/_generic.help.html:9
+#: ../templates/minimal/html/_generic.help.html:9
+msgid "${property} help - ${tracker}"
+msgstr "Aide concernant ${property} - ${tracker}"
+
+#: ../templates/classic/html/_generic.help.html:31
+#: ../templates/minimal/html/_generic.help.html:31
+msgid " Cancel "
+msgstr " Annuler "
+
+#: ../templates/classic/html/_generic.help.html:34
+#: ../templates/minimal/html/_generic.help.html:34
+msgid " Apply "
+msgstr " Appliquer "
+
+#: ../templates/classic/html/_generic.help.html:41
+#: ../templates/classic/html/issue.index.html:73
+#: ../templates/minimal/html/_generic.help.html:41
+msgid "&lt;&lt; previous"
+msgstr "&lt;&lt; pr&eacute;c&eacute;dents"
+
+#: ../templates/classic/html/_generic.help.html:52
+#: ../templates/classic/html/issue.index.html:81
+#: ../templates/minimal/html/_generic.help.html:52
+msgid "${start}..${end} out of ${total}"
+msgstr "${start}..${end} sur ${total}"
+
+#: ../templates/classic/html/_generic.help.html:56
+#: ../templates/classic/html/issue.index.html:84
+#: ../templates/minimal/html/_generic.help.html:56
+msgid "next &gt;&gt;"
+msgstr "suivants &gt;&gt;"
+
+#: ../templates/classic/html/_generic.index.html:6
+#: ../templates/classic/html/_generic.item.html:4
+#: ../templates/minimal/html/_generic.index.html:6
+#: ../templates/minimal/html/_generic.item.html:4
+msgid "${class} editing - ${tracker}"
+msgstr "&Eacute;dition de ${class} - ${tracker}"
+
+#: ../templates/classic/html/_generic.index.html:9
+#: ../templates/classic/html/_generic.item.html:7
+#: ../templates/minimal/html/_generic.index.html:9
+#: ../templates/minimal/html/_generic.item.html:7
+msgid "${class} editing"
+msgstr "&Eacute;dition de ${class}"
+
+#: ../templates/classic/html/_generic.index.html:14
+#: ../templates/classic/html/_generic.item.html:12
+#: ../templates/classic/html/file.item.html:9
+#: ../templates/classic/html/issue.index.html:16
+#: ../templates/classic/html/issue.item.html:28
+#: ../templates/classic/html/msg.item.html:26
+#: ../templates/classic/html/user.index.html:9
+#: ../templates/classic/html/user.item.html:28
+#: ../templates/minimal/html/_generic.index.html:14
+#: ../templates/minimal/html/_generic.item.html:12
+#: ../templates/minimal/html/user.index.html:9
+#: ../templates/minimal/html/user.item.html:28
+#: ../templates/minimal/html/user.register.html:14
+msgid "You are not allowed to view this page."
+msgstr "Vous n'&ecirc;tes pas autoris&eacute; &agrave; voir cette page."
+
+#: ../templates/classic/html/_generic.index.html:22
+#: ../templates/minimal/html/_generic.index.html:22
+msgid ""
+"<p class=\"form-help\"> You may edit the contents of the ${classname} class "
+"using this form. Commas, newlines and double quotes (\") must be handled "
+"delicately. You may include commas and newlines by enclosing the values in "
+"double-quotes (\"). Double quotes themselves must be quoted by doubling "
+"(\"\"). </p> <p class=\"form-help\"> Multilink properties have their multiple "
+"values colon (\":\") separated (... ,\"one:two:three\", ...) </p> <p class="
+"\"form-help\"> Remove entries by deleting their line. Add new entries by "
+"appending them to the table - put an X in the id column. </p>"
+msgstr ""
+"<p class=\"form-help\"> Vous pouvez &eacute;diter le contenu de la classe "
+"${classname} en utilisant ce formulaire. Les virgules, passages &agrave; la "
+"ligne guillemets doubles (\") doivent &ecirc;tre g&eacute;r&eacute;s "
+"soigneusement. Vous pouvez ins&eacute;rer des virgules et des passage "
+"&agrave; la ligne en ins&eacute;rant les valeurs dans des guillemets doubles "
+"(\"). Les guillemets doubles elles-m&ecirc;mes doivent &ecirc;tre ins&eacute;"
+"r&eacute;es en les doublant (\"\").</p><p class=\"form-help\">Les "
+"propri&eacute;t&eacute;s des liens multiples doivent s&eacute;parerleurs "
+"valeurs multiples par des double-points (\":\") (... ,\"un:deux:trois"
+"\", ...) </p><p class=\"form-help\"> Enlevez des entr&eacute;es en "
+"effa&ccedil;ant leur ligne. Ajoutez de nouvelles entr&eacute;es en les "
+"ajoutant &agrave; la fin de la table - mettez un \"X\" dans la colonne \"id"
+"\".</p>"
+
+#: ../templates/classic/html/_generic.index.html:44
+#: ../templates/minimal/html/_generic.index.html:44
+msgid "Edit Items"
+msgstr "Modifier des &eacute;l&eacute;ments"
+
+#: ../templates/classic/html/file.index.html:4
+msgid "List of files - ${tracker}"
+msgstr "Liste des fichiers - ${tracker}s"
+
+#: ../templates/classic/html/file.index.html:5
+msgid "List of files"
+msgstr "Liste des fichiers"
+
+#: ../templates/classic/html/file.index.html:10
+msgid "Download"
+msgstr "T&eacute;l&eacute;charger"
+
+#: ../templates/classic/html/file.index.html:11
+#: ../templates/classic/html/file.item.html:22
+msgid "Content Type"
+msgstr "Type de contenu"
+
+#: ../templates/classic/html/file.index.html:12
+msgid "Uploaded By"
+msgstr "T&eacute;l&eacute;charg&eacute; par"
+
+#: ../templates/classic/html/file.index.html:13
+#: ../templates/classic/html/msg.item.html:43
+msgid "Date"
+msgstr "Date"
+
+#: ../templates/classic/html/file.item.html:2
+msgid "File display - ${tracker}"
+msgstr "Affichage de fichier - ${tracker}"
+
+#: ../templates/classic/html/file.item.html:4
+msgid "File display"
+msgstr "Affichage de fichier"
+
+#: ../templates/classic/html/file.item.html:18
+#: ../templates/classic/html/user.item.html:39
+#: ../templates/classic/html/user.register.html:17
+msgid "Name"
+msgstr "Nom"
+
+#: ../templates/classic/html/file.item.html:40
+msgid "download"
+msgstr "t&eacute;l&eacute;chargement"
+
+#: ../templates/classic/html/home.classlist.html:2
+#: ../templates/minimal/html/home.classlist.html:2
+msgid "List of classes - ${tracker}"
+msgstr "Liste des classes - ${tracker}"
+
+#: ../templates/classic/html/home.classlist.html:4
+#: ../templates/minimal/html/home.classlist.html:4
+msgid "List of classes"
+msgstr "Liste des classes"
+
+#: ../templates/classic/html/issue.index.html:7
+msgid "List of issues - ${tracker}"
+msgstr "Liste des demandes - ${tracker}"
+
+#: ../templates/classic/html/issue.index.html:11
+msgid "List of issues"
+msgstr "Liste des demandes"
+
+#: ../templates/classic/html/issue.index.html:22
+#: ../templates/classic/html/issue.item.html:44
+msgid "Priority"
+msgstr "Priorit&eacute;"
+
+#: ../templates/classic/html/issue.index.html:23
+msgid "ID"
+msgstr "ID"
+
+#: ../templates/classic/html/issue.index.html:24
+msgid "Creation"
+msgstr "Cr&eacute;ation"
+
+#: ../templates/classic/html/issue.index.html:25
+msgid "Activity"
+msgstr "Activit&eacute;"
+
+#: ../templates/classic/html/issue.index.html:26
+msgid "Actor"
+msgstr "Acteur"
+
+#: ../templates/classic/html/issue.index.html:27
+msgid "Topic"
+msgstr "Sujet"
+
+#: ../templates/classic/html/issue.index.html:28
+#: ../templates/classic/html/issue.item.html:39
+msgid "Title"
+msgstr "Titre"
+
+#: ../templates/classic/html/issue.index.html:29
+#: ../templates/classic/html/issue.item.html:46
+msgid "Status"
+msgstr "&Eacute;tat"
+
+#: ../templates/classic/html/issue.index.html:30
+msgid "Creator"
+msgstr "Cr&eacute;ateur"
+
+#: ../templates/classic/html/issue.index.html:31
+msgid "Assigned&nbsp;To"
+msgstr "Assign&eacute;&nbsp;&agrave;"
+
+#: ../templates/classic/html/issue.index.html:97
+msgid "Download as CSV"
+msgstr "T&eacute;l&eacute;charger comme CSV"
+
+#: ../templates/classic/html/issue.index.html:105
+msgid "Sort on:"
+msgstr "Trier par:"
+
+#: ../templates/classic/html/issue.index.html:108
+#: ../templates/classic/html/issue.index.html:125
+msgid "- nothing -"
+msgstr "- rien -"
+
+#: ../templates/classic/html/issue.index.html:116
+#: ../templates/classic/html/issue.index.html:133
+msgid "Descending:"
+msgstr "Descendant:"
+
+#: ../templates/classic/html/issue.index.html:122
+msgid "Group on:"
+msgstr "Grouper par:"
+
+#: ../templates/classic/html/issue.index.html:139
+msgid "Redisplay"
+msgstr "Ré-afficher"
+
+#: ../templates/classic/html/issue.item.html:7
+msgid "Issue ${id}: ${title} - ${tracker}"
+msgstr "Demande ${id}: ${title} - ${tracker}"
+
+#: ../templates/classic/html/issue.item.html:10
+msgid "New Issue - ${tracker}"
+msgstr "Nouvelle demande - ${tracker}"
+
+#: ../templates/classic/html/issue.item.html:14
+msgid "New Issue"
+msgstr "Nouvelle demande"
+
+#: ../templates/classic/html/issue.item.html:16
+msgid "New Issue Editing"
+msgstr "Édition d'une nouvelle demande"
+
+#: ../templates/classic/html/issue.item.html:19
+msgid "Issue${id}"
+msgstr "Issue${id}"
+
+#: ../templates/classic/html/issue.item.html:22
+msgid "Issue${id} Editing"
+msgstr "&Eacute;dition de la demande Issue${id}"
+
+#: ../templates/classic/html/issue.item.html:51
+msgid "Superseder"
+msgstr "Supplant&eacute; par"
+
+#: ../templates/classic/html/issue.item.html:56
+msgid "View: ${link}"
+msgstr "Voir: ${link}"
+
+#: ../templates/classic/html/issue.item.html:60
+msgid "Nosy List"
+msgstr "Liste des curieux"
+
+#: ../templates/classic/html/issue.item.html:69
+msgid "Assigned To"
+msgstr "Assign&eacute; &agrave;"
+
+#: ../templates/classic/html/issue.item.html:71
+msgid "Topics"
+msgstr "Sujets"
+
+#: ../templates/classic/html/issue.item.html:79
+msgid "Change Note"
+msgstr "Note de modification"
+
+#: ../templates/classic/html/issue.item.html:87
+msgid "File"
+msgstr "Fichier"
+
+#: ../templates/classic/html/issue.item.html:99
+msgid "Make a copy"
+msgstr "R&eacute;aliser une copie"
+
+#: ../templates/classic/html/issue.item.html:107
+#: ../templates/classic/html/user.item.html:106
+#: ../templates/classic/html/user.register.html:69
+#: ../templates/minimal/html/user.item.html:86
+msgid ""
+"<table class=\"form\"> <tr> <td>Note:&nbsp;</td> <th class=\"required"
+"\">highlighted</th> <td>&nbsp;fields are required.</td> </tr> </table>"
+msgstr ""
+"<table class=\"form\"> <tr> <td>Note:&nbsp;Les champs&nbsp;</td> <th class="
+"\"required\">mis en &eacute;vidence</th> <td>&nbsp;sont requis.</td> </tr> </"
+"table>"
+
+#: ../templates/classic/html/issue.item.html:121
+msgid ""
+"Created on <b>${creation}</b> by <b>${creator}</b>, last changed <b>"
+"${activity}</b> by <b>${actor}</b>."
+msgstr ""
+"Cr&eacute;&eacute; le <b>${creation}</b> par <b>${creator}</b>, "
+"modifi&eacute; le <b>${activity}</b> par <b>${actor}</b>."
+
+#: ../templates/classic/html/issue.item.html:125
+#: ../templates/classic/html/msg.item.html:56
+msgid "Files"
+msgstr "Fichiers"
+
+#: ../templates/classic/html/issue.item.html:127
+#: ../templates/classic/html/msg.item.html:58
+msgid "File name"
+msgstr "Nom de fichier"
+
+#: ../templates/classic/html/issue.item.html:128
+#: ../templates/classic/html/msg.item.html:59
+msgid "Uploaded"
+msgstr "T&eacute;l&eacute;charg&eacute;"
+
+#: ../templates/classic/html/issue.item.html:129
+msgid "Type"
+msgstr "Type"
+
+#: ../templates/classic/html/issue.item.html:130
+#: ../templates/classic/html/query.edit.html:30
+msgid "Edit"
+msgstr "Modifier"
+
+#: ../templates/classic/html/issue.item.html:131
+msgid "Remove"
+msgstr "Effacer"
+
+#: ../templates/classic/html/issue.item.html:151
+#: ../templates/classic/html/issue.item.html:172
+#: ../templates/classic/html/query.edit.html:50
+msgid "remove"
+msgstr "enlever"
+
+#: ../templates/classic/html/issue.item.html:158
+#: ../templates/classic/html/msg.index.html:9
+msgid "Messages"
+msgstr "Messages"
+
+#: ../templates/classic/html/issue.item.html:162
+msgid "msg${id} (view)"
+msgstr "msg${id} (voir)"
+
+#: ../templates/classic/html/issue.item.html:163
+msgid "Author: ${author}"
+msgstr "Auteur: ${author}"
+
+#: ../templates/classic/html/issue.item.html:165
+msgid "Date: ${date}"
+msgstr "Date: ${date}"
+
+#: ../templates/classic/html/issue.search.html:2
+msgid "Issue searching - ${tracker}"
+msgstr "Recherche de demande - ${tracker}"
+
+#: ../templates/classic/html/issue.search.html:4
+msgid "Issue searching"
+msgstr "Recherche de demande"
+
+#: ../templates/classic/html/issue.search.html:25
+msgid "Filter on"
+msgstr "Filter sur"
+
+#: ../templates/classic/html/issue.search.html:26
+msgid "Display"
+msgstr "Afficher"
+
+#: ../templates/classic/html/issue.search.html:27
+msgid "Sort on"
+msgstr "Trier par"
+
+#: ../templates/classic/html/issue.search.html:28
+msgid "Group on"
+msgstr "Grouper par"
+
+#: ../templates/classic/html/issue.search.html:32
+msgid "All text*:"
+msgstr "Tout le texte*:"
+
+#: ../templates/classic/html/issue.search.html:40
+msgid "Title:"
+msgstr "Titre:"
+
+#: ../templates/classic/html/issue.search.html:50
+msgid "Topic:"
+msgstr "Sujet:"
+
+#: ../templates/classic/html/issue.search.html:58
+msgid "ID:"
+msgstr "ID:"
+
+#: ../templates/classic/html/issue.search.html:66
+msgid "Creation Date:"
+msgstr "Date de cr&eacute;ation:"
+
+#: ../templates/classic/html/issue.search.html:77
+msgid "Creator:"
+msgstr "Cr&eacute;ateur:"
+
+#: ../templates/classic/html/issue.search.html:79
+msgid "created by me"
+msgstr "cr&eacute;&eacute; par moi"
+
+#: ../templates/classic/html/issue.search.html:88
+msgid "Activity:"
+msgstr "Activit&eacute;:"
+
+#: ../templates/classic/html/issue.search.html:99
+msgid "Actor:"
+msgstr "Acteur:"
+
+#: ../templates/classic/html/issue.search.html:101
+msgid "done by me"
+msgstr "fait par moi"
+
+#: ../templates/classic/html/issue.search.html:112
+msgid "Priority:"
+msgstr "Priorit&eacute;:"
+
+#: ../templates/classic/html/issue.search.html:114
+#: ../templates/classic/html/issue.search.html:130
+msgid "not selected"
+msgstr "non s&eacute;lectionn&eacute;"
+
+#: ../templates/classic/html/issue.search.html:125
+msgid "Status:"
+msgstr "&Eacute;tat:"
+
+#: ../templates/classic/html/issue.search.html:128
+msgid "not resolved"
+msgstr "non r&eacute;solu"
+
+#: ../templates/classic/html/issue.search.html:143
+msgid "Assigned to:"
+msgstr "Assign&eacute; &agrave;:"
+
+#: ../templates/classic/html/issue.search.html:146
+msgid "assigned to me"
+msgstr "assign&eacute; &agrave; moi"
+
+#: ../templates/classic/html/issue.search.html:148
+msgid "unassigned"
+msgstr "non assign&eacute;"
+
+#: ../templates/classic/html/issue.search.html:158
+msgid "No Sort or group:"
+msgstr "Aucun tri ou groupe:"
+
+#: ../templates/classic/html/issue.search.html:166
+msgid "Pagesize:"
+msgstr "Taille de la page:"
+
+#: ../templates/classic/html/issue.search.html:172
+msgid "Start With:"
+msgstr "Commence par"
+
+#: ../templates/classic/html/issue.search.html:178
+msgid "Sort Descending:"
+msgstr "Tri descendant:"
+
+#: ../templates/classic/html/issue.search.html:185
+msgid "Group Descending:"
+msgstr "Groupage descendant:"
+
+#: ../templates/classic/html/issue.search.html:192
+msgid "Query name**:"
+msgstr "Nom de requête**:"
+
+#: ../templates/classic/html/issue.search.html:204
+#: ../templates/classic/html/page.html:31
+#: ../templates/classic/html/page.html:60
+#: ../templates/minimal/html/page.html:31
+msgid "Search"
+msgstr "Rechercher"
+
+#: ../templates/classic/html/issue.search.html:209
+msgid "*: The \"all text\" field will look in message bodies and issue titles"
+msgstr "*: Le champ \"tout le texte\" recherchera dans tous les corps de message et "
+"les titres de demande"
+
+#: ../templates/classic/html/issue.search.html:212
+msgid ""
+"**: If you supply a name, the query will be saved off and available as a link "
+"in the sidebar"
+msgstr "**: Si vous donnez un nom, la requ&ecirc;te sera "
+"sauvegard&eacute;e et disponible comme lien dans la barre lat&eacute;rale"
+
+#: ../templates/classic/html/keyword.item.html:3
+msgid "Keyword editing - ${tracker}"
+msgstr "&Eacute;dition de mots-cl&eacute; - ${tracker}"
+
+#: ../templates/classic/html/keyword.item.html:5
+msgid "Keyword editing"
+msgstr "&Eacute;dition de mots-cl&eacute;"
+
+#: ../templates/classic/html/keyword.item.html:11
+msgid "Existing Keywords"
+msgstr "Mots-cl&eacute; existants"
+
+#: ../templates/classic/html/keyword.item.html:20
+msgid ""
+"To edit an existing keyword (for spelling or typing errors), click on its "
+"entry above."
+msgstr ""
+"Pour modifier un mot-cl&eacute; existant (pour les erreurs d'orthographe et "
+"de frappe), cliquez sur son entr&eacute;e ci-dessus."
+
+#: ../templates/classic/html/keyword.item.html:27
+msgid "To create a new keyword, enter it below and click \"Submit New Entry\"."
+msgstr ""
+"Pour cr&eacute;er un nouveau mot-cl&eacute;, entrez-le ci-dessous et cliquer "
+"\"Soumettre une nouvelle entr&eacute;e\"."
+
+#: ../templates/classic/html/keyword.item.html:37
+msgid "Keyword"
+msgstr "Mot-cl&eacute;"
+
+#: ../templates/classic/html/msg.index.html:3
+msgid "List of messages - ${tracker}"
+msgstr "Liste de messages - ${tracker}"
+
+#: ../templates/classic/html/msg.index.html:5
+msgid "Message listing"
+msgstr "Liste de messages"
+
+#: ../templates/classic/html/msg.item.html:6
+msgid "Message ${id} - ${tracker}"
+msgstr "Message ${id} - ${tracker}"
+
+#: ../templates/classic/html/msg.item.html:9
+msgid "New Message - ${tracker}"
+msgstr "Nouveau message - ${tracker}"
+
+#: ../templates/classic/html/msg.item.html:13
+msgid "New Message"
+msgstr "Nouveau message"
+
+#: ../templates/classic/html/msg.item.html:15
+msgid "New Message Editing"
+msgstr "&Eacute;dition d'un nouveau message"
+
+#: ../templates/classic/html/msg.item.html:18
+msgid "Message${id}"
+msgstr "Message${id}"
+
+#: ../templates/classic/html/msg.item.html:21
+msgid "Message${id} Editing"
+msgstr "&Eacute;dition de Message${id}"
+
+#: ../templates/classic/html/msg.item.html:33
+msgid "Author"
+msgstr "Auteur"
+
+#: ../templates/classic/html/msg.item.html:38
+msgid "Recipients"
+msgstr "Destinataires"
+
+#: ../templates/classic/html/msg.item.html:49
+msgid "Content"
+msgstr "Contenu"
+
+#: ../templates/classic/html/page.html:41
+msgid "<b>Your Queries</b> (<a href=\"query?@template=edit\">edit</a>)"
+msgstr ""
+"<b>Vos requ&ecirc;tes</b> (<a href=\"query?@template=edit\">modifier</a>)"
+
+#: ../templates/classic/html/page.html:52
+msgid "Issues"
+msgstr "Demandes"
+
+#: ../templates/classic/html/page.html:54
+#: ../templates/classic/html/page.html:74
+msgid "Create New"
+msgstr "Cr&eacute;er"
+
+#: ../templates/classic/html/page.html:56
+msgid "Show Unassigned"
+msgstr "Montrer les non-assign&eacute;s"
+
+#: ../templates/classic/html/page.html:58
+msgid "Show All"
+msgstr "Montrer tout"
+
+#: ../templates/classic/html/page.html:61
+msgid "Show issue:"
+msgstr "Montrer la demande:"
+
+#: ../templates/classic/html/page.html:72
+msgid "Keywords"
+msgstr "Mots-cl&eacute;"
+
+#: ../templates/classic/html/page.html:78
+msgid "Edit Existing"
+msgstr "Modifier"
+
+#: ../templates/classic/html/page.html:84
+#: ../templates/minimal/html/page.html:65
+msgid "Administration"
+msgstr "Administration"
+
+#: ../templates/classic/html/page.html:86
+#: ../templates/minimal/html/page.html:66
+msgid "Class List"
+msgstr "Liste des classes"
+
+#: ../templates/classic/html/page.html:90
+#: ../templates/minimal/html/page.html:68
+msgid "User List"
+msgstr "Liste des utilisateurs"
+
+#: ../templates/classic/html/page.html:92
+#: ../templates/minimal/html/page.html:71
+msgid "Add User"
+msgstr "Ajouter un utilisateur"
+
+#: ../templates/classic/html/page.html:99
+#: ../templates/classic/html/page.html:105
+#: ../templates/minimal/html/page.html:46
+msgid "Login"
+msgstr "Se connecter"
+
+#: ../templates/classic/html/page.html:104
+#: ../templates/minimal/html/page.html:45
+msgid "Remember me?"
+msgstr "Se souvenir de moi ?"
+
+#: ../templates/classic/html/page.html:108
+#: ../templates/classic/html/user.register.html:63
+#: ../templates/minimal/html/page.html:50
+#: ../templates/minimal/html/user.register.html:58
+msgid "Register"
+msgstr "S'enregistrer"
+
+#: ../templates/classic/html/page.html:111
+msgid "Lost&nbsp;your&nbsp;login?"
+msgstr "Perdu&nbsp;votre&nbsp;login&nbsp;?"
+
+#: ../templates/classic/html/page.html:116
+msgid "Hello, ${user}"
+msgstr "Bienvenue, ${user}"
+
+#: ../templates/classic/html/page.html:118
+msgid "Your Issues"
+msgstr "Vos demandes"
+
+#: ../templates/classic/html/page.html:119
+#: ../templates/minimal/html/page.html:57
+msgid "Your Details"
+msgstr "Vos d&eacute;tails"
+
+#: ../templates/classic/html/page.html:121
+#: ../templates/minimal/html/page.html:59
+msgid "Logout"
+msgstr "Se d&eacute;connecter"
+
+#: ../templates/classic/html/page.html:125
+msgid "Help"
+msgstr "Aide"
+
+#: ../templates/classic/html/page.html:126
+msgid "Roundup docs"
+msgstr "Documentation de Roundup"
+
+#: ../templates/classic/html/page.html:136
+#: ../templates/minimal/html/page.html:81
+msgid "clear this message"
+msgstr "Supprimer ce message"
+
+#: ../templates/classic/html/page.html:181
+msgid "don't care"
+msgstr "aucune importance"
+
+#: ../templates/classic/html/page.html:183
+msgid "------------"
+msgstr ""
+
+#: ../templates/classic/html/page.html:210
+msgid "no value"
+msgstr "pas de valeur"
+
+#: ../templates/classic/html/query.edit.html:4
+msgid "\"Your Queries\" Editing - ${tracker}"
+msgstr "&Eacute;dition de \"Vos requ&ecirc;tes\" - ${tracker}"
+
+#: ../templates/classic/html/query.edit.html:6
+msgid "\"Your Queries\" Editing"
+msgstr "&Eacute;dition de \"Vos requ&ecirc;tes\""
+
+#: ../templates/classic/html/query.edit.html:11
+msgid "You are not allowed to edit queries."
+msgstr "Vous n'avez pas l'autorisation d'&eacute;diter des requ&ecirc;tes."
+
+#: ../templates/classic/html/query.edit.html:28
+msgid "Query"
+msgstr "Requ&ecirc;te"
+
+#: ../templates/classic/html/query.edit.html:29
+msgid "Include in \"Your Queries\""
+msgstr "Inclus dans \"Vos requ&ecirc;tes\""
+
+#: ../templates/classic/html/query.edit.html:31
+msgid "Private to you?"
+msgstr "Priv&eacute; ?"
+
+#: ../templates/classic/html/query.edit.html:44
+msgid "leave out"
+msgstr "sortir"
+
+#: ../templates/classic/html/query.edit.html:45
+msgid "include"
+msgstr "inclure"
+
+#: ../templates/classic/html/query.edit.html:49
+msgid "leave in"
+msgstr "entrer"
+
+#: ../templates/classic/html/query.edit.html:54
+msgid "[query is retired]"
+msgstr "[requ&ecirc;te abandonn&eacute;e]"
+
+#: ../templates/classic/html/query.edit.html:67
+#: ../templates/classic/html/query.edit.html:92
+msgid "edit"
+msgstr "&eacute;diter"
+
+#: ../templates/classic/html/query.edit.html:71
+msgid "yes"
+msgstr "oui"
+
+#: ../templates/classic/html/query.edit.html:73
+msgid "no"
+msgstr "non"
+
+#: ../templates/classic/html/query.edit.html:79
+msgid "Delete"
+msgstr "Effacer"
+
+#: ../templates/classic/html/query.edit.html:94
+msgid "[not yours to edit]"
+msgstr "[ne vous appartient pas]"
+
+#: ../templates/classic/html/query.edit.html:102
+msgid "Save Selection"
+msgstr "Sauvegarder la sélection"
+
+#: ../templates/classic/html/user.forgotten.html:3
+msgid "Password reset request - ${tracker}"
+msgstr "Demande de r&eacute;initialisation de mot de passe - ${tracker}"
+
+#: ../templates/classic/html/user.forgotten.html:5
+msgid "Password reset request"
+msgstr "Demande de r&eacute;initialisation de mot de passe"
+
+#: ../templates/classic/html/user.forgotten.html:9
+msgid ""
+"You have two options if you have forgotten your password. If you know the "
+"email address you registered with, enter it below."
+msgstr ""
+"Vous avez deux solutions si vous avez oubli&eacute; votre mot de passe. Si "
+"vous connaissez l'adresse de messagerie avec laquelle vous vous &ecirc;tes "
+"enregistr&eacute;, introduisez-l&agrave; ci-dessous."
+
+#: ../templates/classic/html/user.forgotten.html:16
+msgid "Email Address:"
+msgstr "Adresse de messagerie:"
+
+#: ../templates/classic/html/user.forgotten.html:24
+#: ../templates/classic/html/user.forgotten.html:34
+msgid "Request password reset"
+msgstr "Demander une réinitialisation du mot de passe"
+
+#: ../templates/classic/html/user.forgotten.html:30
+msgid "Or, if you know your username, then enter it below."
+msgstr ""
+"ou, si vous connaissez votre nom d'utilisateur, introduisez-le ci-dessous."
+
+#: ../templates/classic/html/user.forgotten.html:33
+msgid "Username:"
+msgstr "Nom d'utilisateur:"
+
+#: ../templates/classic/html/user.forgotten.html:39
+msgid ""
+"A confirmation email will be sent to you - please follow the instructions "
+"within it to complete the reset process."
+msgstr ""
+"un courriel de confirmation va vous &ecirc;tre envoy&eacute; - veuillez "
+"suivre les instructions qui y sont donn&eacute;es pour terminer le processus "
+"de r&eacute;initialisation de votre mot de passe."
+
+#: ../templates/classic/html/user.index.html:3
+#: ../templates/minimal/html/user.index.html:3
+msgid "User listing - ${tracker}"
+msgstr "Liste des utilisateurs - ${tracker}"
+
+#: ../templates/classic/html/user.index.html:5
+#: ../templates/minimal/html/user.index.html:5
+msgid "User listing"
+msgstr "Liste des utilisateurs"
+
+#: ../templates/classic/html/user.index.html:14
+#: ../templates/minimal/html/user.index.html:14
+msgid "Username"
+msgstr "Nom d'utilisateur"
+
+#: ../templates/classic/html/user.index.html:15
+msgid "Real name"
+msgstr "Nom r&eacute;el"
+
+#: ../templates/classic/html/user.index.html:16
+#: ../templates/classic/html/user.item.html:70
+#: ../templates/classic/html/user.register.html:45
+msgid "Organisation"
+msgstr "Organisation"
+
+#: ../templates/classic/html/user.index.html:17
+#: ../templates/minimal/html/user.index.html:15
+msgid "Email address"
+msgstr "Adresse de messagerie"
+
+#: ../templates/classic/html/user.index.html:18
+msgid "Phone number"
+msgstr "Num&eacute;ro de t&eacute;l&eacute;phone"
+
+#: ../templates/classic/html/user.index.html:19
+msgid "Retire"
+msgstr "Abandonner"
+
+#: ../templates/classic/html/user.index.html:32
+msgid "retire"
+msgstr "abandonner"
+
+#: ../templates/classic/html/user.item.html:7
+#: ../templates/minimal/html/user.item.html:7
+msgid "User ${id}: ${title} - ${tracker}"
+msgstr "Utilisateur ${id}: ${title} - ${tracker}"
+
+#: ../templates/classic/html/user.item.html:10
+#: ../templates/minimal/html/user.item.html:10
+msgid "New User - ${tracker}"
+msgstr "Nouvel utilisateur - ${tracker}"
+
+#: ../templates/classic/html/user.item.html:14
+#: ../templates/minimal/html/user.item.html:14
+msgid "New User"
+msgstr "Nouvel utilisateur"
+
+#: ../templates/classic/html/user.item.html:16
+#: ../templates/minimal/html/user.item.html:16
+msgid "New User Editing"
+msgstr "&Eacute;dition d'un nouvel utilisateur"
+
+#: ../templates/classic/html/user.item.html:19
+#: ../templates/minimal/html/user.item.html:19
+msgid "User${id}"
+msgstr "User${id}"
+
+#: ../templates/classic/html/user.item.html:22
+#: ../templates/minimal/html/user.item.html:22
+msgid "User${id} Editing"
+msgstr "&Eacute;dition de User${id}"
+
+#: ../templates/classic/html/user.item.html:43
+#: ../templates/classic/html/user.register.html:21
+#: ../templates/minimal/html/user.item.html:40
+#: ../templates/minimal/html/user.register.html:26
+msgid "Login Name"
+msgstr "Nom d'utilisateur"
+
+#: ../templates/classic/html/user.item.html:47
+#: ../templates/classic/html/user.register.html:25
+#: ../templates/minimal/html/user.item.html:44
+#: ../templates/minimal/html/user.register.html:30
+msgid "Login Password"
+msgstr "Mot de passe"
+
+#: ../templates/classic/html/user.item.html:51
+#: ../templates/classic/html/user.register.html:29
+#: ../templates/minimal/html/user.item.html:48
+#: ../templates/minimal/html/user.register.html:34
+msgid "Confirm Password"
+msgstr "Confirmation du mot de passe"
+
+#: ../templates/classic/html/user.item.html:55
+#: ../templates/classic/html/user.register.html:33
+#: ../templates/minimal/html/user.item.html:52
+#: ../templates/minimal/html/user.register.html:38
+msgid "Roles"
+msgstr "R&ocirc;les"
+
+#: ../templates/classic/html/user.item.html:61
+#: ../templates/minimal/html/user.item.html:58
+msgid "(to give the user more than one role, enter a comma,separated,list)"
+msgstr ""
+"(pour donner à l'utilisateur plus d'un r&ocirc;le, introduisez une liste,"
+"s&eacute;par&eacute;e,par,des,virgules)"
+
+#: ../templates/classic/html/user.item.html:66
+#: ../templates/classic/html/user.register.html:41
+msgid "Phone"
+msgstr "T&eacute;l&eacute;phone"
+
+#: ../templates/classic/html/user.item.html:74
+msgid "Timezone"
+msgstr "Fuseau horaire"
+
+#: ../templates/classic/html/user.item.html:78
+msgid "(this is a numeric hour offset, the default is ${zone})"
+msgstr ""
+"(il s'agit d'un d&eacute;calage horaire num&eacute;rique, par d&eacute;faut: "
+"${zone})"
+
+#: ../templates/classic/html/user.item.html:83
+#: ../templates/classic/html/user.register.html:49
+#: ../templates/minimal/html/user.item.html:63
+#: ../templates/minimal/html/user.register.html:46
+msgid "E-mail address"
+msgstr "Adresse de messagerie"
+
+#: ../templates/classic/html/user.item.html:91
+#: ../templates/classic/html/user.register.html:53
+#: ../templates/minimal/html/user.item.html:71
+#: ../templates/minimal/html/user.register.html:50
+msgid "Alternate E-mail addresses<br>One address per line"
+msgstr "Adresses de messagerie alternatives<br>Une adresse par ligne"
+
+#: ../templates/classic/html/user.register.html:4
+#: ../templates/classic/html/user.register.html:7
+#: ../templates/minimal/html/user.register.html:4
+#: ../templates/minimal/html/user.register.html:7
+msgid "Registering with ${tracker}"
+msgstr "Inscription aupr&egrave;s de ${tracker}"
+
+#: ../templates/classic/html/user.rego_progress.html:4
+#: ../templates/minimal/html/user.rego_progress.html:4
+msgid "Registration in progress - ${tracker}"
+msgstr "Inscription en cours - ${tracker}"
+
+#: ../templates/classic/html/user.rego_progress.html:6
+#: ../templates/minimal/html/user.rego_progress.html:6
+msgid "Registration in progress..."
+msgstr "Inscription en cours..."
+
+#: ../templates/classic/html/user.rego_progress.html:10
+#: ../templates/minimal/html/user.rego_progress.html:10
+msgid ""
+"You will shortly receive an email to confirm your registration. To complete "
+"the registration process, visit the link indicated in the email."
+msgstr ""
+"Vous recevrez sous peu un courriel confirmant votre inscription. Pour "
+"cl&ocirc;turer le processus d'inscription, veuillez suivre le lien "
+"indiqu&eacute; dans le courriel."
+
+#: ../templates/minimal/html/home.html:2
+msgid "Tracker home - ${tracker}"
+msgstr "Base du pisteur - ${tracker}"
+
+#: ../templates/minimal/html/home.html:4
+msgid "Tracker home"
+msgstr "Base du pisteur"
+
+#: ../templates/minimal/html/home.html:16
+msgid "Please select from one of the menu options on the left."
+msgstr ""
+"Veuillez s&eacute;lectionner l'une des options de menu &agrave; la gauche."
+
+#: ../templates/minimal/html/home.html:19
+msgid "Please log in or register."
+msgstr "Veuillez vous connecter ou vous inscrire."
+
+#: ../templates/minimal/html/page.html:55
+msgid "Hello,<br>${user}"
+msgstr "Bienvenue,<br>${user}"

Added: tracker/vendor/roundup/current/locale/lt.po
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/locale/lt.po	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,3129 @@
+# Lithuanian message file for Roundup Issue Tracker
+# Aiste Kesminaite <aiste at pov.lt>, 2005
+#
+# $Id: lt.po,v 1.5 2006/03/04 08:29:18 a1s Exp $
+#
+# roundup.pot revision 1.17
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: roundup-1.1.0\n"
+"Report-Msgid-Bugs-To: roundup-devel at lists.sourceforge.net\n"
+"POT-Creation-Date: 2006-02-28 07:44+0200\n"
+"PO-Revision-Date: 2006-03-04 10:10+0200\n"
+"Last-Translator: Nerijus Baliunas <nerijus at users.sourceforge.net>\n"
+"Language-Team:\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && (n%"
+"100<10 || n%100>=20) ? 1 : 2;\n"
+
+# ../roundup/admin.py:85 :962 :1011 :1033
+#: ../roundup/admin.py:85 ../roundup/admin.py:979 ../roundup/admin.py:1028
+#: ../roundup/admin.py:1050
+#, python-format
+msgid "no such class \"%(classname)s\""
+msgstr "nÄ—ra klasÄ—s \"%(classname)s\""
+
+# ../roundup/admin.py:95 :99
+#: ../roundup/admin.py:95 ../roundup/admin.py:99
+#, python-format
+msgid "argument \"%(arg)s\" not propname=value"
+msgstr "argumentas \"%(arg)s\" nėra parinktis=reikšmė formato"
+
+#: ../roundup/admin.py:112
+#, python-format
+msgid ""
+"Problem: %(message)s\n"
+"\n"
+msgstr ""
+"Problema: %(message)s\n"
+"\n"
+
+#: ../roundup/admin.py:113
+#, python-format
+msgid ""
+"%(message)sUsage: roundup-admin [options] [<command> <arguments>]\n"
+"\n"
+"Options:\n"
+" -i instance home  -- specify the issue tracker \"home directory\" to "
+"administer\n"
+" -u                -- the user[:password] to use for commands\n"
+" -d                -- print full designators not just class id numbers\n"
+" -c                -- when outputting lists of data, comma-separate them.\n"
+"                      Same as '-S \",\"'.\n"
+" -S <string>       -- when outputting lists of data, string-separate them\n"
+" -s                -- when outputting lists of data, space-separate them.\n"
+"                      Same as '-S \" \"'.\n"
+"\n"
+" Only one of -s, -c or -S can be specified.\n"
+"\n"
+"Help:\n"
+" roundup-admin -h\n"
+" roundup-admin help                       -- this help\n"
+" roundup-admin help <command>             -- command-specific help\n"
+" roundup-admin help all                   -- all available help\n"
+msgstr ""
+"%(message)sNaudojimas: roundup-admin [parinktys] [<komanda> <argumentai>]\n"
+"\n"
+"Variantai:\n"
+" -i instance home  -- roundup'o \"namų direktorija\" administravimui\n"
+" -u                -- vartotojas[:slaptažodis] komandoms autentifikuoti\n"
+" -d                -- išspausdinti pilnus dezignatorius, ne tik klasės id\n"
+"                      numerius\n"
+" -c                -- išvedant duomenų sąrašus, atskirti juos kableliais.\n"
+"                      Taip pat kaip '-S \",\"'.\n"
+" -S <string>       -- išvedant duomenų sąrašus, atskirti stringu.\n"
+" -s                -- išvedant duomenų sąrašus, atskirti juos tarpais.\n"
+"                      Taip pat kaip '-S \" \"'.\n"
+"\n"
+" Tik vienas iš -s, -c ar -S gali būti nurodyta.\n"
+"\n"
+"Pagalba:\n"
+" roundup-admin -h\n"
+" roundup-admin help                       -- Å¡is pagalbos puslapis\n"
+" roundup-admin help <komanda>             -- specifinÄ— pagalba komandoms\n"
+" roundup-admin help all                   -- visa įmanoma pagalba\n"
+
+#: ../roundup/admin.py:138
+msgid "Commands:"
+msgstr "Komandos:"
+
+#: ../roundup/admin.py:145
+msgid ""
+"Commands may be abbreviated as long as the abbreviation\n"
+"matches only one command, e.g. l == li == lis == list."
+msgstr ""
+"Komandos gali būti sutrumpintos, tačiau sutrumpinimas turi atitikti tik\n"
+"vienÄ… komandÄ…, pvz. l == li == lis == list."
+
+#: ../roundup/admin.py:175
+msgid ""
+"\n"
+"All commands (except help) require a tracker specifier. This is just\n"
+"the path to the roundup tracker you're working with. A roundup tracker\n"
+"is where roundup keeps the database and configuration file that defines\n"
+"an issue tracker. It may be thought of as the issue tracker's \"home\n"
+"directory\". It may be specified in the environment variable TRACKER_HOME\n"
+"or on the command line as \"-i tracker\".\n"
+"\n"
+"A designator is a classname and a nodeid concatenated, eg. bug1, "
+"user10, ...\n"
+"\n"
+"Property values are represented as strings in command arguments and in the\n"
+"printed results:\n"
+" . Strings are, well, strings.\n"
+" . Date values are printed in the full date format in the local time zone,\n"
+"   and accepted in the full format or any of the partial formats explained\n"
+"   below.\n"
+" . Link values are printed as node designators. When given as an argument,\n"
+"   node designators and key strings are both accepted.\n"
+" . Multilink values are printed as lists of node designators joined\n"
+"   by commas.  When given as an argument, node designators and key\n"
+"   strings are both accepted; an empty string, a single node, or a list\n"
+"   of nodes joined by commas is accepted.\n"
+"\n"
+"When property values must contain spaces, just surround the value with\n"
+"quotes, either ' or \". A single space may also be backslash-quoted. If a\n"
+"value must contain a quote character, it must be backslash-quoted or inside\n"
+"quotes. Examples:\n"
+"           hello world      (2 tokens: hello, world)\n"
+"           \"hello world\"    (1 token: hello world)\n"
+"           \"Roch'e\" Compaan (2 tokens: Roch'e Compaan)\n"
+"           Roch\\'e Compaan  (2 tokens: Roch'e Compaan)\n"
+"           address=\"1 2 3\"  (1 token: address=1 2 3)\n"
+"           \\\\               (1 token: \\)\n"
+"           \\n\\r\\t           (1 token: a newline, carriage-return and "
+"tab)\n"
+"\n"
+"When multiple nodes are specified to the roundup get or roundup set\n"
+"commands, the specified properties are retrieved or set on all the listed\n"
+"nodes.\n"
+"\n"
+"When multiple results are returned by the roundup get or roundup find\n"
+"commands, they are printed one per line (default) or joined by commas (with\n"
+"the -c) option.\n"
+"\n"
+"Where the command changes data, a login name/password is required. The\n"
+"login may be specified as either \"name\" or \"name:password\".\n"
+" . ROUNDUP_LOGIN environment variable\n"
+" . the -u command-line option\n"
+"If either the name or password is not supplied, they are obtained from the\n"
+"command-line.\n"
+"\n"
+"Date format examples:\n"
+"  \"2000-04-17.03:45\" means <Date 2000-04-17.08:45:00>\n"
+"  \"2000-04-17\" means <Date 2000-04-17.00:00:00>\n"
+"  \"01-25\" means <Date yyyy-01-25.00:00:00>\n"
+"  \"08-13.22:13\" means <Date yyyy-08-14.03:13:00>\n"
+"  \"11-07.09:32:43\" means <Date yyyy-11-07.14:32:43>\n"
+"  \"14:25\" means <Date yyyy-mm-dd.19:25:00>\n"
+"  \"8:47:11\" means <Date yyyy-mm-dd.13:47:11>\n"
+"  \".\" means \"right now\"\n"
+"\n"
+"Command help:\n"
+msgstr ""
+"\n"
+"Visos komandos (išskyrus help) reikalauja tracker'io specifikatoriaus.\n"
+"Tai yra tiesiog kelias iki roundup tracker'io, su kuriuo dirbate. Roundup\n"
+"laiko duomenų bazę ir konfigūracijos failą, kuris aprašo kreipinių valdymo\n"
+"sistemą būtent ten. Apie jį galima galvoti kaip apie kreipinių valdymo\n"
+"sistemos \"namų direktoriją\". Jis gali būti nurodytas aplinkos "
+"kintamajame \n"
+"TRACKER_HOME arba komandinÄ—je eilutÄ—je kaip \"-i tracker\".\n"
+"\n"
+"Dezignatorius - tai klasÄ—s vardas sujungtas su elemento id, pvz.\n"
+"vartotojas10, kreipinys1.  \n"
+"\n"
+"Atributo reikšmės yra rodomos kaip simbolių eilutės komandų argumentuose ir\n"
+"atvaizduojamuose rezultatuose:\n"
+" . Simbolių eilutės yra, hm... simbolių eilutės.\n"
+" . Datos reikšmės yra atvaizduojamos pilnu datos formatu lokalioje laiko\n"
+"   zonoje, ir priimamos pilnu formatu arba bet kuriuo iš dalinių formatų,\n"
+"   paaiškintų žemiau.\n"
+" . Saito reikšmės yra atvaizduojamos kaip elemento dezignatoriai. Kai jos\n"
+"   pateikiamos kaip argumentai, priimami ir elemento dezignatoriai, ir \n"
+"   raktų simbolių eilutės. \n"
+" . Daugiasaitės reikšmės yra atvaizduojamos kaip elemento dezignatorių\n"
+"   sąrašai, sujungti kableliais. Kai jos pateikiamos kaip argumentai, yra \n"
+"   priimami ir elementų dezignatoriai, ir raktų simbolių eilutės; tuščia\n"
+"   eilutė, vienas elementas ar elementų sąrašas yra taip pat priimami.\n"
+"\n"
+"Kai atributo reikšmėse yra būtini tarpai, tiesiog apskliauskite reikšmę\n"
+"kabutėmis, ' arba \". Vienas tarpas taipogi gali būti paslepiamas \n"
+"atvirkštiniu pasviru brūkšneliu. Jei vertėje būtinas kabučių simbolis, jis\n"
+"turi būti paslepiamas atvirkštiniu pasviru brūkšneliu arba apskliaudžiamas \n"
+"kabutÄ—mis.\n"
+"Pavyzdžiai:\n"
+"           hello world      (2 leksemos: hello, world)\n"
+"           \"hello world\"    (1 leksema: hello world)\n"
+"           \"Roch'e\" Compaan (2 leksemos: Roch'e Compaan)\n"
+"           Roch\\'e Compaan  (2 leksemos: Roch'e Compaan)\n"
+"           address=\"1 2 3\"  (1 leksema: address=1 2 3)\n"
+"           \\\\               (1 leksema: \\)\n"
+"           \\n\\r\\t           (1 leksema: LF, CR ir TAB simboliai)\n"
+"\n"
+"Kai roundup'o get ar set komandoms yra paduodami keli elementai, nurodyti\n"
+"atributai yra gaunami ar nustatomi visiems paduotiems elementams. \n"
+"\n"
+"Kai roundup get ar roundup find komandos grąžina kelis rezultatus, jie yra \n"
+"paprastai atvaizduojami po vienÄ… eilutÄ—je arba sujungiami kableliais \n"
+"(nurodžius -c parinktis).\n"
+"\n"
+"Kur komanda pakeičia duomenis, reikalaujamas vartotojo vardas/slaptažodis.\n"
+"Vartotojo vardas gali būti nurodomas kaip \"vartotojas\" arba \n"
+"\"vartotojas:slaptažodis\".\n"
+" . ROUNDUP_LOGIN aplinkos kintamasis\n"
+" . -u komandinÄ—s eilutÄ—s parinktis\n"
+"Jei arba vartotojo vardas, arba slaptažodis nėra pateikiamas, jie gaunami \n"
+"iš komandinės eilutės. \n"
+"\n"
+"Datos formato pavyzdžiai:\n"
+"  \"2000-04-17.03:45\" reiškia <Data 2000-04-17.08:45:00>\n"
+"  \"2000-04-17\" reiškia <Data 2000-04-17.00:00:00>\n"
+"  \"01-25\" reiškia <Data yyyy-01-25.00:00:00>\n"
+"  \"08-13.22:13\" reiškia <Data yyyy-08-14.03:13:00>\n"
+"  \"11-07.09:32:43\" reiškia <Data yyyy-11-07.14:32:43>\n"
+"  \"14:25\" reiškia <Data yyyy-mm-dd.19:25:00>\n"
+"  \"8:47:11\" reiškia <Data yyyy-mm-dd.13:47:11>\n"
+"  \".\" reiškia \"dabar\"\n"
+"\n"
+"Komandų pagalba:\n"
+
+#: ../roundup/admin.py:238
+#, python-format
+msgid "%s:"
+msgstr "%s:"
+
+#: ../roundup/admin.py:243
+msgid ""
+"Usage: help topic\n"
+"        Give help about topic.\n"
+"\n"
+"        commands  -- list commands\n"
+"        <command> -- help specific to a command\n"
+"        initopts  -- init command options\n"
+"        all       -- all available help\n"
+"        "
+msgstr ""
+"Naudojimas: help tema\n"
+"        Pagalba atitinkama tema.\n"
+"\n"
+"        commands  -- išvardinti komandas\n"
+"        <komanda> -- pagalba specifinei komandai\n"
+"        initopts  -- init komandų parinktys\n"
+"        all       -- visa įmanoma pagalba\n"
+"        "
+
+#: ../roundup/admin.py:266
+#, python-format
+msgid "Sorry, no help for \"%(topic)s\""
+msgstr "Atsiprašome, pagalbos temai \"%(topic)s\" nėra"
+
+# ../roundup/admin.py:338 :387
+#: ../roundup/admin.py:338 ../roundup/admin.py:394
+msgid "Templates:"
+msgstr "Å ablonai:"
+
+# ../roundup/admin.py:341 :398
+#: ../roundup/admin.py:341 ../roundup/admin.py:405
+msgid "Back ends:"
+msgstr "Duomenų saugyklos:"
+
+#: ../roundup/admin.py:344
+msgid ""
+"Usage: install [template [backend [admin password [key=val[,key=val]]]]]\n"
+"        Install a new Roundup tracker.\n"
+"\n"
+"        The command will prompt for the tracker home directory\n"
+"        (if not supplied through TRACKER_HOME or the -i option).\n"
+"        The template, backend and admin password may be specified\n"
+"        on the command-line as arguments, in that order.\n"
+"\n"
+"        The last command line argument allows to pass initial values\n"
+"        for config options.  For example, passing\n"
+"        \"web_http_auth=no,rdbms_user=dinsdale\" will override defaults\n"
+"        for options http_auth in section [web] and user in section [rdbms].\n"
+"        Please be careful to not use spaces in this argument! (Enclose\n"
+"        whole argument in quotes if you need spaces in option value).\n"
+"\n"
+"        The initialise command must be called after this command in order\n"
+"        to initialise the tracker's database. You may edit the tracker's\n"
+"        initial database contents before running that command by editing\n"
+"        the tracker's dbinit.py module init() function.\n"
+"\n"
+"        See also initopts help.\n"
+"        "
+msgstr ""
+"Naudojimas: install [šablonas [saugykla [administratoriaus slaptažodis "
+"[raktas=reikšmė[,raktas=reikšmė]]]]]\n"
+"            Įdiegti naują Roundup kreipinių valdymo sistemą.\n"
+"\n"
+"            Ši komanda paprašys tracker'io namų direktorijos (jei nepaduota\n"
+"            per TRACKER_HOME ar -i parinktį). Šablonas, duomenų saugykla ir\n"
+"            administratoriaus slaptažodis gali būti nurodyti kaip\n"
+"            argumentai komandinėje eilutėje būtent šia tvarka.\n"
+"\n"
+"            Paskutinis komandinÄ—s eilutÄ—s argumentas priskiria pradines\n"
+"            reikšmes konfigūracijos parinktims.  Pavyzdžiui, nurodydami\n"
+"            \"web_http_auth=no,rdbms_user=dinsdale\" pakeisite http_auth\n"
+"            sekcijoje [web] ir user sekcijoje [rdbms] parinkčių reikšmes\n"
+"            pagal nutylėjimą. Nenaudokite tarpų šiame argumente (visą\n"
+"            argumentą rašykite kabutėse, jei parinkties reikšmėje yra "
+"tarpų).\n"
+"\n"
+"            Inicializavimo komanda turi būti pateikta po šios komandos tam,\n"
+"            kad inicializuotųsi tracker'io duomenų bazė. Jūs galite "
+"pakeisti\n"
+"            pradinį tracker'io duomenų bazės turinį prieš paleisdami šią\n"
+"            komandÄ…, pakeisdami tracker'io dbinit.py modulio init() "
+"funkcijÄ….\n"
+"\n"
+"            Taip pat pažiūrėkite initopts pagalbą.\n"
+"        "
+
+# ../roundup/admin.py:360 :447 :508 :587 :637 :695 :716 :744 :815 :882 :953
+# :1001 :1023 :1050 :1117 :1184
+#: ../roundup/admin.py:367 ../roundup/admin.py:464 ../roundup/admin.py:525
+#: ../roundup/admin.py:604 ../roundup/admin.py:654 ../roundup/admin.py:712
+#: ../roundup/admin.py:733 ../roundup/admin.py:761 ../roundup/admin.py:832
+#: ../roundup/admin.py:899 ../roundup/admin.py:970 ../roundup/admin.py:1018
+#: ../roundup/admin.py:1040 ../roundup/admin.py:1067 ../roundup/admin.py:1134
+#: ../roundup/admin.py:1204
+msgid "Not enough arguments supplied"
+msgstr "Paduota nepakankamai argumentų"
+
+#: ../roundup/admin.py:373
+#, python-format
+msgid "Instance home parent directory \"%(parent)s\" does not exist"
+msgstr "Namų direktorijos tėvinė direktorija \"%(parent)s\" neegzistuoja"
+
+#: ../roundup/admin.py:381
+#, python-format
+msgid ""
+"WARNING: There appears to be a tracker in \"%(tracker_home)s\"!\n"
+"If you re-install it, you will lose all the data!\n"
+"Erase it? Y/N: "
+msgstr ""
+"PERSPÄ–JIMAS: \"%(tracker_home)s\" jau yra tracker'is!\n"
+"Jei jūs jį perdiegsite, prarasite visus duomenis!\n"
+"Ištrinti jį? Y/N: "
+
+#: ../roundup/admin.py:396
+msgid "Select template [classic]: "
+msgstr "Pasirinkite Å¡ablonÄ… [klasikinis]: "
+
+#: ../roundup/admin.py:407
+msgid "Select backend [anydbm]: "
+msgstr "Pasirinkite duomenų saugyklą [anydbm]: "
+
+#: ../roundup/admin.py:417
+#, python-format
+msgid "Error in configuration settings: \"%s\""
+msgstr "Klaida konfigūracijos nustatymuose: \"%s\""
+
+#: ../roundup/admin.py:426
+#, python-format
+msgid ""
+"\n"
+"---------------------------------------------------------------------------\n"
+" You should now edit the tracker configuration file:\n"
+"   %(config_file)s"
+msgstr ""
+"\n"
+"---------------------------------------------------------------------------\n"
+" Dabar jūs turėtumėte pakeisti tracker'io konfigūracijos failą:\n"
+"   %(config_file)s"
+
+#: ../roundup/admin.py:436
+msgid " ... at a minimum, you must set following options:"
+msgstr " ... mažiausiai turėtumėte nustalyti šias parinktis:"
+
+#: ../roundup/admin.py:441
+#, python-format
+msgid ""
+"\n"
+" If you wish to modify the database schema,\n"
+" you should also edit the schema file:\n"
+"   %(database_config_file)s\n"
+" You may also change the database initialisation file:\n"
+"   %(database_init_file)s\n"
+" ... see the documentation on customizing for more information.\n"
+"\n"
+" You MUST run the \"roundup-admin initialise\" command once you've "
+"performed\n"
+" the above steps.\n"
+"---------------------------------------------------------------------------\n"
+msgstr ""
+"\n"
+" Jei jūs norite keisti duomenų bazės schema,\n"
+" jūs taip pat turėtumėte pakeisti schema failą:\n"
+"   %(database_config_file)s\n"
+" Jūs taip pat galite pakeisti duomenų bazės inicializacijos failą:\n"
+"   %(database_init_file)s\n"
+" ... jei reikia daugiau informacijos, žiūrėkite dokumentaciją apie \n"
+" pakeitimus.\n"
+"\n"
+" JÅ«s PRIVALOTE paleisti \"roundup-admin initialise\" komandÄ…, kai atliksite\n"
+" aukščiau minėtus žingsnius.\n"
+"---------------------------------------------------------------------------\n"
+
+#: ../roundup/admin.py:459
+msgid ""
+"Usage: genconfig <filename>\n"
+"        Generate a new tracker config file (ini style) with default values\n"
+"        in <filename>.\n"
+"        "
+msgstr ""
+"Naudojimas: genconfig <failovardas>\n"
+"            Generuoti naują tracker'io konfigūracijos failą (ini tipo) su\n"
+"            įprastomis reikšmėmis faile <failovardas>.\n"
+"        "
+
+#. password
+#: ../roundup/admin.py:469
+msgid ""
+"Usage: initialise [adminpw]\n"
+"        Initialise a new Roundup tracker.\n"
+"\n"
+"        The administrator details will be set at this step.\n"
+"\n"
+"        Execute the tracker's initialisation function dbinit.init()\n"
+"        "
+msgstr ""
+"Naudojimas: initialise [adminslaptažodis]\n"
+"            Inicializuoti naują Roundup kreipinių valdymo sistemą.\n"
+"\n"
+"            Administratoriaus pasirinkimai bus nustatomi šiuo žingsniu.\n"
+"\n"
+"            Vykdyti tracker'io inicializacijos funkcijÄ… dbinit.init()\n"
+"        "
+
+#: ../roundup/admin.py:483
+msgid "Admin Password: "
+msgstr "Administratoriaus slaptažodis: "
+
+#: ../roundup/admin.py:484
+msgid "       Confirm: "
+msgstr "       Patvirtinkite: "
+
+#: ../roundup/admin.py:488
+msgid "Instance home does not exist"
+msgstr "Namų direktorija neegzistuoja"
+
+#: ../roundup/admin.py:492
+msgid "Instance has not been installed"
+msgstr "Egzempliorius nebuvo įdiegtas"
+
+#: ../roundup/admin.py:497
+msgid ""
+"WARNING: The database is already initialised!\n"
+"If you re-initialise it, you will lose all the data!\n"
+"Erase it? Y/N: "
+msgstr ""
+"PERSPĖJIMAS: Duomenų bazė jau inicializuota!\n"
+"Jei jūs ją inicializuosite dar kartą, prarasite visus duomenis!\n"
+"Ištrinti duomenų bazę? Y/N: "
+
+#: ../roundup/admin.py:518
+msgid ""
+"Usage: get property designator[,designator]*\n"
+"        Get the given property of one or more designator(s).\n"
+"\n"
+"        Retrieves the property value of the nodes specified\n"
+"        by the designators.\n"
+"        "
+msgstr ""
+"Naudojimas: get parinktis dezignatorius[,dezignatorius]*\n"
+"            Gauti pateikto vieno ar kelių dezignatorių parinktį.\n"
+"\n"
+"            Gauna elementų, nurodytų dezignatoriais parinkties reikšmę.\n"
+"        "
+
+# ../roundup/admin.py:541 :556
+#: ../roundup/admin.py:558 ../roundup/admin.py:573
+#, python-format
+msgid "property %s is not of type Multilink or Link so -d flag does not apply."
+msgstr ""
+"parinktis %s nėra Multilink ar Link tipo, komandų eilutės parametras\n"
+"-d netinkamas."
+
+# ../roundup/admin.py:564 :964 :1013 :1035
+#: ../roundup/admin.py:581 ../roundup/admin.py:981 ../roundup/admin.py:1030
+#: ../roundup/admin.py:1052
+#, python-format
+msgid "no such %(classname)s node \"%(nodeid)s\""
+msgstr "nÄ—ra tokio %(classname)s elemento \"%(nodeid)s\""
+
+#: ../roundup/admin.py:583
+#, python-format
+msgid "no such %(classname)s property \"%(propname)s\""
+msgstr "nÄ—ra tokio %(classname)s parinkties \"%(propname)s\""
+
+#: ../roundup/admin.py:592
+msgid ""
+"Usage: set items property=value property=value ...\n"
+"        Set the given properties of one or more items(s).\n"
+"\n"
+"        The items are specified as a class or as a comma-separated\n"
+"        list of item designators (ie \"designator[,designator,...]\").\n"
+"\n"
+"        This command sets the properties to the values for all designators\n"
+"        given. If the value is missing (ie. \"property=\") then the "
+"property\n"
+"        is un-set. If the property is a multilink, you specify the linked\n"
+"        ids for the multilink as comma-separated numbers (ie \"1,2,3\").\n"
+"        "
+msgstr ""
+"Naudojimas: set elementas parinktis=reikšmė parinktis=reikšmė ...\n"
+"            Nustatyti pateiktas parinktis vienam ar keliems elementams.\n"
+"\n"
+"            Elementai nnurodomi kaip klasÄ— arba kaip kableliais atskirtas\n"
+"            sąrašas elementų dezignatorių \n"
+"            (t.y \"dezignatorius[, dezignatorius,...]\").\n"
+"\n"
+"            Ši komanda priskiria reikšmes visų duotų dezignatorių\n"
+"            parinktims. Jei reikšmės nėra (t.y. \"parinktis=\"), tada \n"
+"            parinkties reikšmė yra grąžinama į standartinę. Jei parinktis\n"
+"            yra daugiasaitÄ—, nurodomi susieti id kaip kableliais atskirtos\n"
+"            reikšmės (t.y. \"1,2,3\").\n"
+"        "
+
+#: ../roundup/admin.py:646
+msgid ""
+"Usage: find classname propname=value ...\n"
+"        Find the nodes of the given class with a given link property value.\n"
+"\n"
+"        Find the nodes of the given class with a given link property value.\n"
+"        The value may be either the nodeid of the linked node, or its key\n"
+"        value.\n"
+"        "
+msgstr ""
+"Naudojimas: find klasėsvardas parinktis=reikšmė ...\n"
+"            Rasti duotos klasės elementus, atitinkančius pateiktos sąsajos\n"
+"            parinkties reikšmę.\n"
+"\n"
+"            Rasti duotos klasės elementus, atitinkančius pateiktos sqsajos\n"
+"            parinkties reikšmę. Reikšmė gali būti arba susieto elemento id\n"
+"            arba jo raktinė reikšmė.\n"
+"        "
+
+# ../roundup/admin.py:682 :835 :847 :901
+#: ../roundup/admin.py:699 ../roundup/admin.py:852 ../roundup/admin.py:864
+#: ../roundup/admin.py:918
+#, python-format
+msgid "%(classname)s has no property \"%(propname)s\""
+msgstr "%(classname)s neturi parinkties \"%(propname)s\""
+
+#: ../roundup/admin.py:706
+msgid ""
+"Usage: specification classname\n"
+"        Show the properties for a classname.\n"
+"\n"
+"        This lists the properties for a given class.\n"
+"        "
+msgstr ""
+"Naudojimas: specification klasÄ—svardas\n"
+"            Rodyti klasÄ—svardas parinktis.\n"
+"\n"
+"            Ši komanda išvardina duotos klasės parinktis.\n"
+"        "
+
+#: ../roundup/admin.py:721
+#, python-format
+msgid "%(key)s: %(value)s (key property)"
+msgstr "%(key)s: %(value)s (key property)"
+
+#: ../roundup/admin.py:723
+#, python-format
+msgid "%(key)s: %(value)s"
+msgstr "%(key)s: %(value)s"
+
+#: ../roundup/admin.py:726
+msgid ""
+"Usage: display designator[,designator]*\n"
+"        Show the property values for the given node(s).\n"
+"\n"
+"        This lists the properties and their associated values for the given\n"
+"        node.\n"
+"        "
+msgstr ""
+"Naudojimas: display dezignatorius[, dezignatorius]*\n"
+"            Rodyti duoto elemento(ų) parinkties reikšmes.\n"
+"\n"
+"            Ši komanda išvardina parinktis ir jų reikšmes duotam elementui.\n"
+"        "
+
+#: ../roundup/admin.py:750
+#, python-format
+msgid "%(key)s: %(value)r"
+msgstr "%(key)s: %(value)r"
+
+#: ../roundup/admin.py:753
+msgid ""
+"Usage: create classname property=value ...\n"
+"        Create a new entry of a given class.\n"
+"\n"
+"        This creates a new entry of the given class using the property\n"
+"        name=value arguments provided on the command line after the \"create"
+"\"\n"
+"        command.\n"
+"        "
+msgstr ""
+"Naudojimas: create klasėsvardas parinktis=reikšmė ...\n"
+"            Sukurti naują įrašą duotai klasei.\n"
+"\n"
+"            Ši komanda sukuria naują įrašą duotai klasei naudodama\n"
+"            parinkties vardas=reikšmė argumentus, pateikiamus komandinėje\n"
+"            eilutÄ—je po \"create\" komandos.\n"
+"        "
+
+#: ../roundup/admin.py:780
+#, python-format
+msgid "%(propname)s (Password): "
+msgstr "%(propname)s (Slaptažodis): "
+
+#: ../roundup/admin.py:782
+#, python-format
+msgid "   %(propname)s (Again): "
+msgstr "   %(propname)s (Pakartoti): "
+
+#: ../roundup/admin.py:784
+msgid "Sorry, try again..."
+msgstr "Bandykite dar kartÄ…..."
+
+#: ../roundup/admin.py:788
+#, python-format
+msgid "%(propname)s (%(proptype)s): "
+msgstr "%(propname)s (%(proptype)s): "
+
+#: ../roundup/admin.py:806
+#, python-format
+msgid "you must provide the \"%(propname)s\" property."
+msgstr "turite pateikti parinktį \"%(propname)s\"."
+
+#: ../roundup/admin.py:817
+msgid ""
+"Usage: list classname [property]\n"
+"        List the instances of a class.\n"
+"\n"
+"        Lists all instances of the given class. If the property is not\n"
+"        specified, the  \"label\" property is used. The label property is\n"
+"        tried in order: the key, \"name\", \"title\" and then the first\n"
+"        property, alphabetically.\n"
+"\n"
+"        With -c, -S or -s print a list of item id's if no property\n"
+"        specified.  If property specified, print list of that property\n"
+"        for every class instance.\n"
+"        "
+msgstr ""
+"Naudojimas: list klasÄ—svardas [parinktis]\n"
+"            IÅ¡vardina klasÄ—s egzempliorius.\n"
+"\n"
+"            IÅ¡vardina visus duotos klasÄ—s egzempliorius. Jei parinktis \n"
+"            nenurodyta, naudojama \"label\" parinktis. Ji yra bandoma\n"
+"            iš eilės: raktas, \"vardas\", \"pavadinimas\", o tada pirma\n"
+"            iš eilės parinktis abėcėlės tvarka.\n"
+"\n"
+"            Pasirinkus -c, -S ar -s atvaizduoja sąrašą elementų id jei nėra\n"
+"            nurodyta parinktis. Jei parinktis nurodyta, atvaizduojamas tos\n"
+"            parinkties sąrašas kiekvienam klasės egzemplioriui.\n"
+"        "
+
+#: ../roundup/admin.py:830
+msgid "Too many arguments supplied"
+msgstr "Pateikta per daug argumentų"
+
+#: ../roundup/admin.py:866
+#, python-format
+msgid "%(nodeid)4s: %(value)s"
+msgstr "%(nodeid)4s: %(value)s"
+
+#: ../roundup/admin.py:870
+msgid ""
+"Usage: table classname [property[,property]*]\n"
+"        List the instances of a class in tabular form.\n"
+"\n"
+"        Lists all instances of the given class. If the properties are not\n"
+"        specified, all properties are displayed. By default, the column\n"
+"        widths are the width of the largest value. The width may be\n"
+"        explicitly defined by defining the property as \"name:width\".\n"
+"        For example::\n"
+"\n"
+"          roundup> table priority id,name:10\n"
+"          Id Name\n"
+"          1  fatal-bug\n"
+"          2  bug\n"
+"          3  usability\n"
+"          4  feature\n"
+"\n"
+"        Also to make the width of the column the width of the label,\n"
+"        leave a trailing : without a width on the property. For example::\n"
+"\n"
+"          roundup> table priority id,name:\n"
+"          Id Name\n"
+"          1  fata\n"
+"          2  bug\n"
+"          3  usab\n"
+"          4  feat\n"
+"\n"
+"        will result in a the 4 character wide \"Name\" column.\n"
+"        "
+msgstr ""
+"Naudojimas: table klasÄ—svardas [parinktis[,parinktis]*]\n"
+"            IÅ¡vardina klasÄ—s egzempliorius lentelÄ—s pavidale.\n"
+"\n"
+"            IÅ¡vardina visus duotos klasÄ—s egzempliorius. Jei parinktys\n"
+"            nenurodytos, parodomos visos parinktys. Standartiškai, "
+"stulpelių\n"
+"            plotis yra ilgiausios reikšmės pločio. Plotis gali būti "
+"nurodomas\n"
+"            pateikiant parinktį \"vardas:plotis\".\n"
+"            Pavyzdžiui::\n"
+"\n"
+"                roundup> table priority id,name:10\n"
+"                Id Name\n"
+"                1  fatal-bug\n"
+"                2  bug\n"
+"                3  usability\n"
+"                4  feature\n"
+"\n"
+"            jei norite, kad stulpelio protis atitiktų etiketės plotį, \n"
+"            palikite : nenurodydami parinkties pločio. Pavyzdžiui::\n"
+"\n"
+"                roundup> table priority id,name:\n"
+"                Id Name\n"
+"                1  fata\n"
+"                2  bug\n"
+"                3  usab\n"
+"                4  feat\n"
+"\n"
+"            pateiks 4 simbolių ilgio \"Name\" stulpelį.\n"
+"        "
+
+#: ../roundup/admin.py:914
+#, python-format
+msgid "\"%(spec)s\" not name:width"
+msgstr "\"%(spec)s\" ne vardas:plotis"
+
+#: ../roundup/admin.py:964
+msgid ""
+"Usage: history designator\n"
+"        Show the history entries of a designator.\n"
+"\n"
+"        Lists the journal entries for the node identified by the "
+"designator.\n"
+"        "
+msgstr ""
+"Naudojimas: history dezignatorius\n"
+"            Parodo dezignatoriaus įrašų istoriją.\n"
+"\n"
+"            Parodo žurnalinius įrašus elementui identifikuotam \n"
+"            dezignatoriaus. \n"
+"        "
+
+#: ../roundup/admin.py:985
+msgid ""
+"Usage: commit\n"
+"        Commit changes made to the database during an interactive session.\n"
+"\n"
+"        The changes made during an interactive session are not\n"
+"        automatically written to the database - they must be committed\n"
+"        using this command.\n"
+"\n"
+"        One-off commands on the command-line are automatically committed if\n"
+"        they are successful.\n"
+"        "
+msgstr ""
+"Naudojimas: commit\n"
+"            Išsaugoti pakeitimus atliktus duomenų bazėje interaktyvios\n"
+"            sesijos metu.\n"
+"\n"
+"            Pakeitimai, atlikti interaktyvios sesijos metu, nÄ—ra \n"
+"            automatiškai įrašomi į duomenų bazę - jie turi būti išsaugomi \n"
+"            Å¡ios komandos pagalba.\n"
+"\n"
+"            Vienetinės komandos komandinėje eilutėje yra atutomatiškai \n"
+"            išsaugomos, jei jos įvykdomos sėkmingai.\n"
+"        "
+
+#: ../roundup/admin.py:999
+msgid ""
+"Usage: rollback\n"
+"        Undo all changes that are pending commit to the database.\n"
+"\n"
+"        The changes made during an interactive session are not\n"
+"        automatically written to the database - they must be committed\n"
+"        manually. This command undoes all those changes, so a commit\n"
+"        immediately after would make no changes to the database.\n"
+"        "
+msgstr ""
+"Naudojimas: rollback\n"
+"            Anuliuoti visus pakeitimus duomenų bazėje, kurie turi būti \n"
+"            išsaugoti naudojant commit komandą.\n"
+"\n"
+"             Pakeitimai, atlikti interaktyvios sesijos metu, nÄ—ra\n"
+"             automatiškai įrašomi į duomenų bazę -  jie turi būti\n"
+"             išsaugomi rankinių būdu. Ši komanda anuliuoja visus\n"
+"             pakeitimus, taigi commit komanda iškarto po šios komandos\n"
+"             nepadarys jokių pakeitimų duomenų bazėje.\n"
+"        "
+
+#: ../roundup/admin.py:1011
+msgid ""
+"Usage: retire designator[,designator]*\n"
+"        Retire the node specified by designator.\n"
+"\n"
+"        This action indicates that a particular node is not to be retrieved\n"
+"        by the list or find commands, and its key value may be re-used.\n"
+"        "
+msgstr ""
+"Naudojimas: retire dezignatorius[,dezignatorius]*\n"
+"            Deaktyvuoti elementÄ… nurodyta dezignatoriaus.\n"
+"\n"
+"            Å i komanda nurodo, jog konkretus elementas nerodomas\n"
+"            list ar find komandų ir jo raktas gali būti panaudotas dar \n"
+"            kartÄ….\n"
+"        "
+
+#: ../roundup/admin.py:1034
+msgid ""
+"Usage: restore designator[,designator]*\n"
+"        Restore the retired node specified by designator.\n"
+"\n"
+"        The given nodes will become available for users again.\n"
+"        "
+msgstr ""
+"Naudojimas: restore dezignatorius[,dezignatorius]*\n"
+"            Aktyvuoti deaktyvuotÄ… elementÄ…, nurodomÄ… dezignatoriaus.\n"
+"\n"
+"            Duotas elementas vÄ—l taps prieinamas vartotojams.\n"
+"        "
+
+#. grab the directory to export to
+#: ../roundup/admin.py:1056
+msgid ""
+"Usage: export [class[,class]] export_dir\n"
+"        Export the database to colon-separated-value files.\n"
+"\n"
+"        Optionally limit the export to just the names classes.\n"
+"\n"
+"        This action exports the current data from the database into\n"
+"        colon-separated-value files that are placed in the nominated\n"
+"        destination directory.\n"
+"        "
+msgstr ""
+"Naudojimas: export [klasÄ—[,klasÄ—]] eksporto_direktorija\n"
+"            Eksportuoti duomenų bazę kaip kableliais atskirtų reikšmių \n"
+"            failÄ….\n"
+"\n"
+"            Galima apriboti eksportą tik vardų klasėmis.\n"
+"\n"
+"            Ši komanda eksportuoja dabartinius duomenis iš duomenų bazės\n"
+"            į kableliais atskirtų reikšmių failus, kurie išsaugomi \n"
+"            nurodytoje direktorijoje.\n"
+"        "
+
+#: ../roundup/admin.py:1114
+msgid ""
+"Usage: import import_dir\n"
+"        Import a database from the directory containing CSV files,\n"
+"        two per class to import.\n"
+"\n"
+"        The files used in the import are:\n"
+"\n"
+"        <class>.csv\n"
+"          This must define the same properties as the class (including\n"
+"          having a \"header\" line with those property names.)\n"
+"        <class>-journals.csv\n"
+"          This defines the journals for the items being imported.\n"
+"\n"
+"        The imported nodes will have the same nodeid as defined in the\n"
+"        import file, thus replacing any existing content.\n"
+"\n"
+"        The new nodes are added to the existing database - if you want to\n"
+"        create a new database using the imported data, then create a new\n"
+"        database (or, tediously, retire all the old data.)\n"
+"        "
+msgstr ""
+"Naudojimas: import importo_direktorija\n"
+"            Importuoti duomenų bazę iš direktorijos su CSV failais,\n"
+"            vienai klasei -- du failai.\n"
+"\n"
+"            Failai, kurių reikia importui yra:\n"
+"\n"
+"            <klasÄ—>.csv\n"
+"              Šis failas turi nurodyti tokias pačias parinktis kaip ir\n"
+"              klasė (taip pat turi turėti antraštę su parinkčių vardais.)\n"
+"            <klasÄ—>-journals.csv\n"
+"              Šis failas apibrėžia importuojamų vienetų žurnalus.\n"
+"\n"
+"            Importuoti elementai turės tuos pačius elementų id kaip\n"
+"            nurodyta importo failuose, tokiu būdu pakeisdami bet kokį esamą\n"
+"            turinį.\n"
+"\n"
+"            Nauji elementai pridedami prie esamos duomenų bazės -- jei \n"
+"            norite sukurti naują duomenų bazę naudodami importuojamus \n"
+"            duomenis, tada reikia sukurti naują duomenų bazę (arba \n"
+"            deaktyvuoti visus esamus duomenis -- daug darbo reikalaujantis\n"
+"            veiksmas).\n"
+"        "
+
+#: ../roundup/admin.py:1186
+msgid ""
+"Usage: pack period | date\n"
+"\n"
+"        Remove journal entries older than a period of time specified or\n"
+"        before a certain date.\n"
+"\n"
+"        A period is specified using the suffixes \"y\", \"m\", and \"d\". "
+"The\n"
+"        suffix \"w\" (for \"week\") means 7 days.\n"
+"\n"
+"              \"3y\" means three years\n"
+"              \"2y 1m\" means two years and one month\n"
+"              \"1m 25d\" means one month and 25 days\n"
+"              \"2w 3d\" means two weeks and three days\n"
+"\n"
+"        Date format is \"YYYY-MM-DD\" eg:\n"
+"            2001-01-01\n"
+"\n"
+"        "
+msgstr ""
+"Naudojimas: pack periodas | data\n"
+"\n"
+"            Pašalinti žurnalo įrašus senesnius nei nurodytas laiko tarpas\n"
+"            ar atsiradusius anksčiau nei nurodyta data.\n"
+"\n"
+"            Periodas norodomas naudojant priesagas \"y\", \"m\" ir \"d\".\n"
+"            Priesaga \"w\" (savaitė (week)) reiškia 7 dienas.\n"
+"\n"
+"                     \"3y\" reiškia tris metus\n"
+"                     \"2y 1m\" reiškia du mentus ir vieną mėnesį\n"
+"                     \"1m 25d\" reiškia vieną mėnesį ir 25 dienas\n"
+"                     \"2w 3d\" reiškia dvi saivaites ir tris dienas\n"
+"            \n"
+"            Datos formatas yra \"YYYY-MM-DD\" pvz:\n"
+"                  2001-12-31 \n"
+"\n"
+"        "
+
+#: ../roundup/admin.py:1214
+msgid "Invalid format"
+msgstr "Netinkamas formatas"
+
+#: ../roundup/admin.py:1224
+msgid ""
+"Usage: reindex [classname|designator]*\n"
+"        Re-generate a tracker's search indexes.\n"
+"\n"
+"        This will re-generate the search indexes for a tracker.\n"
+"        This will typically happen automatically.\n"
+"        "
+msgstr ""
+"Naudojimas: reindex [klasÄ—s_vardas|dezignatorius]*\n"
+"            Regeneruoti tracker'io paieškos indeksus.\n"
+"\n"
+"            Ši komanda regeneruoja paieškos indeksus tracker'iui.\n"
+"            Paprastai tai įvyksta automatiškai.\n"
+"        "
+
+#: ../roundup/admin.py:1238
+#, python-format
+msgid "no such item \"%(designator)s\""
+msgstr "nÄ—ra elemento \"%(designator)s\""
+
+#: ../roundup/admin.py:1248
+msgid ""
+"Usage: security [Role name]\n"
+"        Display the Permissions available to one or all Roles.\n"
+"        "
+msgstr ""
+"Naudojimas: security [RolÄ—s pavadinimas]\n"
+"            Parodo vienos ar kelių rolių permisijas.\n"
+"        "
+
+#: ../roundup/admin.py:1256
+#, python-format
+msgid "No such Role \"%(role)s\""
+msgstr "NÄ—ra tokios rolÄ—s \"%(role)s\""
+
+#: ../roundup/admin.py:1262
+#, python-format
+msgid "New Web users get the Roles \"%(role)s\""
+msgstr "Naujiems web vartotojams suteikiamos rolÄ—s \"%(role)s\""
+
+#: ../roundup/admin.py:1264
+#, python-format
+msgid "New Web users get the Role \"%(role)s\""
+msgstr "Naujiems web vartotojams suteikiama rolÄ— \"%(role)s\""
+
+#: ../roundup/admin.py:1267
+#, python-format
+msgid "New Email users get the Roles \"%(role)s\""
+msgstr "Naujiems vartotojams per el. paštą suteikiamos rolės \"%(role)s\""
+
+#: ../roundup/admin.py:1269
+#, python-format
+msgid "New Email users get the Role \"%(role)s\""
+msgstr "Naujiems vartotojams per el. paštą suteikiama rolė \"%(role)s\""
+
+#: ../roundup/admin.py:1272
+#, python-format
+msgid "Role \"%(name)s\":"
+msgstr "RolÄ— \"%(name)s\":"
+
+#: ../roundup/admin.py:1277
+#, python-format
+msgid " %(description)s (%(name)s for \"%(klass)s\": %(properties)s only)"
+msgstr " %(description)s (%(name)s skirta tik \"%(klass)s\": %(properties)s)"
+
+#: ../roundup/admin.py:1280
+#, python-format
+msgid " %(description)s (%(name)s for \"%(klass)s\" only)"
+msgstr " %(description)s (%(name)s skirta tik \"%(klass)s\")"
+
+#: ../roundup/admin.py:1283
+#, python-format
+msgid " %(description)s (%(name)s)"
+msgstr " %(description)s (%(name)s)"
+
+#: ../roundup/admin.py:1312
+#, python-format
+msgid "Unknown command \"%(command)s\" (\"help commands\" for a list)"
+msgstr ""
+"Nežinoma komanda \"%(command)s\" (įveskite \"help commands\" komandų\n"
+"sąrašui gauti)"
+
+#: ../roundup/admin.py:1318
+#, python-format
+msgid "Multiple commands match \"%(command)s\": %(list)s"
+msgstr "Kelios komandos atitinka \"%(command)s\": %(list)s"
+
+#: ../roundup/admin.py:1325
+msgid "Enter tracker home: "
+msgstr "Įveskite tracker'io namų direktoriją: "
+
+# ../roundup/admin.py:1312 :1318 :1338
+#: ../roundup/admin.py:1332 ../roundup/admin.py:1338 ../roundup/admin.py:1358
+#, python-format
+msgid "Error: %(message)s"
+msgstr "Klaida: %(message)s"
+
+#: ../roundup/admin.py:1346
+#, python-format
+msgid "Error: Couldn't open tracker: %(message)s"
+msgstr "Klaida: Negaliu atidaryti tracker'io: %(message)s"
+
+#: ../roundup/admin.py:1371
+#, python-format
+msgid ""
+"Roundup %s ready for input.\n"
+"Type \"help\" for help."
+msgstr ""
+"Roundup %s pasiruošęs priimti duomenis.\n"
+"Norėdami iškviesti pagalbą įveskite \"help\"."
+
+#: ../roundup/admin.py:1376
+msgid "Note: command history and editing not available"
+msgstr "Pastaba: komandų archyvas ir redagavimas neprieinami"
+
+#: ../roundup/admin.py:1380
+msgid "roundup> "
+msgstr "roundup> "
+
+#: ../roundup/admin.py:1382
+msgid "exit..."
+msgstr "išeiti..."
+
+#: ../roundup/admin.py:1392
+msgid "There are unsaved changes. Commit them (y/N)? "
+msgstr "Yra neišsaugotų pakeitimų. Išsaugoti juos (y/N)? "
+
+#: ../roundup/backends/back_anydbm.py:1997
+#, python-format
+msgid "WARNING: invalid date tuple %r"
+msgstr "PERSPÄ–JIMAS: netinkamas datos tuple'as %r"
+
+#: ../roundup/backends/rdbms_common.py:1434
+msgid "create"
+msgstr "sukurti"
+
+#: ../roundup/backends/rdbms_common.py:1600
+msgid "unlink"
+msgstr "atsieti"
+
+#: ../roundup/backends/rdbms_common.py:1604
+msgid "link"
+msgstr "susieti"
+
+#: ../roundup/backends/rdbms_common.py:1724
+msgid "set"
+msgstr "nustatyti"
+
+#: ../roundup/backends/rdbms_common.py:1748
+msgid "retired"
+msgstr "deaktyvuotas"
+
+#: ../roundup/backends/rdbms_common.py:1778
+msgid "restored"
+msgstr "aktyvuotas"
+
+#: ../roundup/cgi/actions.py:58
+#, python-format
+msgid "You do not have permission to %(action)s the %(classname)s class."
+msgstr "JÅ«s neturite leidimo %(action)s %(classname)s klasÄ™."
+
+#: ../roundup/cgi/actions.py:89
+msgid "No type specified"
+msgstr "Nenurodytas tipas"
+
+#: ../roundup/cgi/actions.py:91
+msgid "No ID entered"
+msgstr "Neįvestas ID"
+
+#: ../roundup/cgi/actions.py:97
+#, python-format
+msgid "\"%(input)s\" is not an ID (%(classname)s ID required)"
+msgstr "\"%(input)s\" nÄ—ra ID (reikia %(classname)s ID)"
+
+#: ../roundup/cgi/actions.py:117
+msgid "You may not retire the admin or anonymous user"
+msgstr "Negalite deaktyvuoti administratoriaus ar anoniminio vartotojo"
+
+#: ../roundup/cgi/actions.py:124
+#, python-format
+msgid "%(classname)s %(itemid)s has been retired"
+msgstr "%(classname)s %(itemid)s buvo deaktyvuotas"
+
+# ../roundup/cgi/actions.py:163 :191
+#: ../roundup/cgi/actions.py:174 ../roundup/cgi/actions.py:202
+msgid "You do not have permission to edit queries"
+msgstr "Neturite leidimo redaguoti užklausas"
+
+# ../roundup/cgi/actions.py:169 :197
+#: ../roundup/cgi/actions.py:180 ../roundup/cgi/actions.py:209
+msgid "You do not have permission to store queries"
+msgstr "Neturite leidimo išsaugoti užklausas"
+
+#: ../roundup/cgi/actions.py:297
+#, python-format
+msgid "Not enough values on line %(line)s"
+msgstr "Nepakanka reikšmių eilutėje %(line)s"
+
+#: ../roundup/cgi/actions.py:344
+msgid "Items edited OK"
+msgstr "Elementų pakeitimai išsaugoti"
+
+#: ../roundup/cgi/actions.py:404
+#, python-format
+msgid "%(class)s %(id)s %(properties)s edited ok"
+msgstr "%(class)s %(id)s %(properties)s pakeitimai išsaugoti"
+
+#: ../roundup/cgi/actions.py:407
+#, python-format
+msgid "%(class)s %(id)s - nothing changed"
+msgstr "%(class)s %(id)s - niekas nepakeista"
+
+#: ../roundup/cgi/actions.py:419
+#, python-format
+msgid "%(class)s %(id)s created"
+msgstr "sukurta %(class)s %(id)s"
+
+#: ../roundup/cgi/actions.py:451
+#, python-format
+msgid "You do not have permission to edit %(class)s"
+msgstr "Neturite leidimo redaguoti %(class)s"
+
+#: ../roundup/cgi/actions.py:463
+#, python-format
+msgid "You do not have permission to create %(class)s"
+msgstr "Neturite leidimo sukurti %(class)s"
+
+#: ../roundup/cgi/actions.py:487
+msgid "You do not have permission to edit user roles"
+msgstr "Neturite leidimo redaguoti vartotojų roles"
+
+#: ../roundup/cgi/actions.py:537
+#, python-format
+msgid ""
+"Edit Error: someone else has edited this %s (%s). View <a target=\"new\" "
+"href=\"%s%s\">their changes</a> in a new window."
+msgstr ""
+"Redagavimo klaida: kitas vartotojas redagavo %s (%s). Peržiūrėkite <a target="
+"\"new\" href=\"%s%s\">jų pakeitimus</a> naujame lange."
+
+#: ../roundup/cgi/actions.py:565
+#, python-format
+msgid "Edit Error: %s"
+msgstr "Redagavimo klaida: %s"
+
+# ../roundup/cgi/actions.py:579 :590 :761 :780
+#: ../roundup/cgi/actions.py:596 ../roundup/cgi/actions.py:607
+#: ../roundup/cgi/actions.py:778 ../roundup/cgi/actions.py:797
+#, python-format
+msgid "Error: %s"
+msgstr "Klaida: %s"
+
+#: ../roundup/cgi/actions.py:633
+msgid ""
+"Invalid One Time Key!\n"
+"(a Mozilla bug may cause this message to show up erroneously, please check "
+"your email)"
+msgstr ""
+"Netinkamas One Time Key!\n"
+"(šį pranešimą gali neteisingai sukelti Mozilla klaida, patikrinkite savo "
+"paštą.)"
+
+#: ../roundup/cgi/actions.py:675
+#, python-format
+msgid "Password reset and email sent to %s"
+msgstr "Slaptažodis atstatytas ir el. laiškas išsiųstas %s"
+
+#: ../roundup/cgi/actions.py:684
+msgid "Unknown username"
+msgstr "Nežinomas vartotojo vardas"
+
+#: ../roundup/cgi/actions.py:692
+msgid "Unknown email address"
+msgstr "Nežinomas el. pašto adresas"
+
+#: ../roundup/cgi/actions.py:697
+msgid "You need to specify a username or address"
+msgstr "Privalote nurodyti vartotojo vardą ar el. pašto adresą"
+
+#: ../roundup/cgi/actions.py:722
+#, python-format
+msgid "Email sent to %s"
+msgstr "El. laiškas išsiųstas %s"
+
+#: ../roundup/cgi/actions.py:741
+msgid "You are now registered, welcome!"
+msgstr "Jūs esate užregistruotas, sveiki prisijungę!"
+
+#: ../roundup/cgi/actions.py:786
+msgid "It is not permitted to supply roles at registration."
+msgstr "Negalima pateikti rolių registracijos metu."
+
+#: ../roundup/cgi/actions.py:878
+msgid "You are logged out"
+msgstr "JÅ«s atsijungÄ—te"
+
+#: ../roundup/cgi/actions.py:895
+msgid "Username required"
+msgstr "Reikalingas vartotojo vardas"
+
+# ../roundup/cgi/actions.py:897 :901
+#: ../roundup/cgi/actions.py:930 ../roundup/cgi/actions.py:934
+msgid "Invalid login"
+msgstr "Neteisingas vartotojo vardas"
+
+#: ../roundup/cgi/actions.py:940
+msgid "You do not have permission to login"
+msgstr "Neturite prisijungimo teisių"
+
+#: ../roundup/cgi/cgitb.py:49
+#, python-format
+msgid ""
+"<h1>Templating Error</h1>\n"
+"<p><b>%(exc_type)s</b>: %(exc_value)s</p>\n"
+"<p class=\"help\">Debugging information follows</p>"
+msgstr ""
+"<h1>Å ablono klaida</h1>\n"
+"<p><b>%(exc_type)s</b>: %(exc_value)s</p>\n"
+"<p class=\"help\">Informacija klaidų taisymui</p>"
+
+#: ../roundup/cgi/cgitb.py:64
+#, python-format
+msgid "<li>\"%(name)s\" (%(info)s)</li>"
+msgstr "<li>\"%(name)s\" (%(info)s)</li>"
+
+#: ../roundup/cgi/cgitb.py:67
+#, python-format
+msgid "<li>Looking for \"%(name)s\", current path:<ol>%(path)s</ol></li>"
+msgstr "<li>Ieškau \"%(name)s\", dabartinis kelias :<ol>%(path)s</ol></li>"
+
+#: ../roundup/cgi/cgitb.py:71
+#, python-format
+msgid "<li>In %s</li>"
+msgstr "<li>%s viduje</li>"
+
+#: ../roundup/cgi/cgitb.py:76
+#, python-format
+msgid "A problem occurred in your template \"%s\"."
+msgstr "Rasta klaida jūsų šablone \"%s\"."
+
+#: ../roundup/cgi/cgitb.py:84
+#, python-format
+msgid ""
+"\n"
+"<li>While evaluating the %(info)r expression on line %(line)d\n"
+"<table class=\"otherinfo\" style=\"font-size: 90%%\">\n"
+" <tr><th colspan=\"2\" class=\"header\">Current variables:</th></tr>\n"
+" %(globals)s\n"
+" %(locals)s\n"
+"</table></li>\n"
+msgstr ""
+"\n"
+"<li>Vertinant %(info)r reiškinį eilutėje %(line)d\n"
+"<table class=\"otherinfo\" style=\"font-size: 90%%\">\n"
+" <tr><th colspan=\"2\" class=\"header\">Esami kintamieji:</th></tr>\n"
+" %(globals)s\n"
+" %(locals)s\n"
+"</table></li>\n"
+
+#: ../roundup/cgi/cgitb.py:103
+msgid "Full traceback:"
+msgstr "Pilnas traceback:"
+
+#: ../roundup/cgi/cgitb.py:116
+#, python-format
+msgid "<font size=+1><strong>%(exc_type)s</strong>: %(exc_value)s</font>"
+msgstr "<font size=+1><strong>%(exc_type)s</strong>: %(exc_value)s</font>"
+
+#: ../roundup/cgi/cgitb.py:120
+msgid ""
+"<p>A problem occurred while running a Python script. Here is the sequence of "
+"function calls leading up to the error, with the most recent (innermost) "
+"call first. The exception attributes are:"
+msgstr ""
+"<p>Iškilo problema leidžiant Python skriptą. Čia pateikta seka funkcijų "
+"iškvietimų iki klaidos, kur naujausias (giliausias) iškvietimas yra pirmas. "
+"Klaidos atributai yra:"
+
+#: ../roundup/cgi/cgitb.py:129
+msgid "&lt;file is None - probably inside <tt>eval</tt> or <tt>exec</tt>&gt;"
+msgstr ""
+"&lt;failas yra None - greičiausiai viduje <tt>eval</tt> ar <tt>exec</tt>&gt;"
+
+#: ../roundup/cgi/cgitb.py:138
+#, python-format
+msgid "in <strong>%s</strong>"
+msgstr "<strong>%s</strong> viduje"
+
+# ../roundup/cgi/cgitb.py:172 :178
+#: ../roundup/cgi/cgitb.py:172 ../roundup/cgi/cgitb.py:178
+msgid "<em>undefined</em>"
+msgstr "<em>neapibrėžta</em>"
+
+#: ../roundup/cgi/client.py:49
+msgid ""
+"<html><head><title>An error has occurred</title></head>\n"
+"<body><h1>An error has occurred</h1>\n"
+"<p>A problem was encountered processing your request.\n"
+"The tracker maintainers have been notified of the problem.</p>\n"
+"</body></html>"
+msgstr ""
+"<html><head><title>Klaida</title></head>\n"
+"<body><h1>Klaida</h1>\n"
+"<p>Įvyko klaida vykdant jūsų užklausą.\n"
+"Apie klaidą pranešėme tracker'io administratoriui.</p>\n"
+"</body></html>"
+
+#: ../roundup/cgi/client.py:308
+msgid "Form Error: "
+msgstr "Formos klaida: "
+
+#: ../roundup/cgi/client.py:363
+#, python-format
+msgid "Unrecognized charset: %r"
+msgstr "Neatpažinta koduotė: %r"
+
+#: ../roundup/cgi/client.py:490
+msgid "Anonymous users are not allowed to use the web interface"
+msgstr "Anoniminiai vartotojai neturi teisių naudoti web interfeisą"
+
+#: ../roundup/cgi/client.py:645
+msgid "You are not allowed to view this file."
+msgstr "Jūs neturite teisių žiūrėti šį failą."
+
+#: ../roundup/cgi/client.py:737
+#, python-format
+msgid "%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n"
+msgstr "%(starttag)sPraėjęs laikas: %(seconds)fs%(endtag)s\n"
+
+#: ../roundup/cgi/client.py:741
+#, python-format
+msgid ""
+"%(starttag)sCache hits: %(cache_hits)d, misses %(cache_misses)d. Loading "
+"items: %(get_items)f secs. Filtering: %(filtering)f secs.%(endtag)s\n"
+msgstr ""
+"%(starttag)sAtmintinÄ—s atitikimai: %(cache_hits)d, neatitikimai %"
+"(cache_misses)d. Įkeliami elementai: %(get_items)f sek. Filtruojama: %"
+"(filtering)f sek.%(endtag)s\n"
+
+#: ../roundup/cgi/form_parser.py:283
+#, python-format
+msgid "link \"%(key)s\" value \"%(value)s\" not a designator"
+msgstr "sąsajos \"%(key)s\" reikšmė \"%(value)s\" nėra dezignatorius"
+
+#: ../roundup/cgi/form_parser.py:290
+#, python-format
+msgid "%(class)s %(property)s is not a link or multilink property"
+msgstr "%(class)s %(property)s nÄ—ra sÄ…sajos ar multisÄ…sajos parinktis"
+
+#: ../roundup/cgi/form_parser.py:312
+#, python-format
+msgid ""
+"You have submitted a %(action)s action for the property \"%(property)s\" "
+"which doesn't exist"
+msgstr ""
+"Jūs pateikėte %(action)s komandą parinkčiai \"%(property)s\", kuri "
+"neegzistuoja"
+
+# ../roundup/cgi/form_parser.py:331 :357
+#: ../roundup/cgi/form_parser.py:331 ../roundup/cgi/form_parser.py:357
+#, python-format
+msgid "You have submitted more than one value for the %s property"
+msgstr "Jūs pateikėte daugiau nei vieną reikšmę parinkčiai %s"
+
+# ../roundup/cgi/form_parser.py:354 :360
+#: ../roundup/cgi/form_parser.py:354 ../roundup/cgi/form_parser.py:360
+msgid "Password and confirmation text do not match"
+msgstr "Slaptažodis ir patvirtinimo tekstas neatitinka"
+
+#: ../roundup/cgi/form_parser.py:395
+#, python-format
+msgid "property \"%(propname)s\": \"%(value)s\" not currently in list"
+msgstr "parinkties \"%(propname)s\": \"%(value)s\" nėra sąraše"
+
+#: ../roundup/cgi/form_parser.py:512
+#, python-format
+msgid "Required %(class)s property %(property)s not supplied"
+msgid_plural "Required %(class)s properties %(property)s not supplied"
+msgstr[0] "Reikalinga %(class)s parinktis %(property)s nepateikta"
+msgstr[1] "Reikalingos %(class)s parinktys %(property)s nepateiktos"
+msgstr[2] "Reikalingos %(class)s parinktys %(property)s nepateiktos"
+
+#: ../roundup/cgi/form_parser.py:535
+msgid "File is empty"
+msgstr "Failas tuščias"
+
+#: ../roundup/cgi/templating.py:72
+#, python-format
+msgid "You are not allowed to %(action)s items of class %(class)s"
+msgstr ""
+"JÅ«s negalite atlikti komandos %(action)s su klasÄ—s %(class)s elementais"
+
+#: ../roundup/cgi/templating.py:627
+msgid "(list)"
+msgstr "(list)"
+
+#: ../roundup/cgi/templating.py:696
+msgid "Submit New Entry"
+msgstr "Įvesti naują įrašą"
+
+# ../roundup/cgi/templating.py:700 :819 :1193 :1214 :1258 :1280 :1314 :1353
+# :1404 :1421 :1497 :1517 :1530 :1547 :1557 :1607 :1794
+#: ../roundup/cgi/templating.py:710 ../roundup/cgi/templating.py:829
+#: ../roundup/cgi/templating.py:1236 ../roundup/cgi/templating.py:1257
+#: ../roundup/cgi/templating.py:1304 ../roundup/cgi/templating.py:1327
+#: ../roundup/cgi/templating.py:1361 ../roundup/cgi/templating.py:1400
+#: ../roundup/cgi/templating.py:1453 ../roundup/cgi/templating.py:1470
+#: ../roundup/cgi/templating.py:1549 ../roundup/cgi/templating.py:1569
+#: ../roundup/cgi/templating.py:1587 ../roundup/cgi/templating.py:1619
+#: ../roundup/cgi/templating.py:1629 ../roundup/cgi/templating.py:1683
+#: ../roundup/cgi/templating.py:1875
+msgid "[hidden]"
+msgstr "[paslÄ—pta]"
+
+#: ../roundup/cgi/templating.py:711
+msgid "New node - no history"
+msgstr "Naujas elementas -- nÄ—ra istorijos"
+
+#: ../roundup/cgi/templating.py:811
+msgid "Submit Changes"
+msgstr "IÅ¡saugoti pakeitimus"
+
+#: ../roundup/cgi/templating.py:893
+msgid "<em>The indicated property no longer exists</em>"
+msgstr "<em>Nurodytos parinkties nÄ—ra</em>"
+
+#: ../roundup/cgi/templating.py:894
+#, python-format
+msgid "<em>%s: %s</em>\n"
+msgstr "<em>%s: %s</em>\n"
+
+#: ../roundup/cgi/templating.py:907
+#, python-format
+msgid "The linked class %(classname)s no longer exists"
+msgstr "Susietos klasÄ—s %(classname)s nebÄ—ra"
+
+# ../roundup/cgi/templating.py:930 :951
+#: ../roundup/cgi/templating.py:940 ../roundup/cgi/templating.py:964
+msgid "<strike>The linked node no longer exists</strike>"
+msgstr "<strike>Susieto elemento nebÄ—ra</strike>"
+
+# ../roundup/cgi/templating.py:993 :1357 :1378 :1384
+#: ../roundup/cgi/templating.py:1006 ../roundup/cgi/templating.py:1404
+#: ../roundup/cgi/templating.py:1425 ../roundup/cgi/templating.py:1431
+msgid "No"
+msgstr "Ne"
+
+# ../roundup/cgi/templating.py:993 :1357 :1376 :1381
+#: ../roundup/cgi/templating.py:1006 ../roundup/cgi/templating.py:1404
+#: ../roundup/cgi/templating.py:1423 ../roundup/cgi/templating.py:1428
+msgid "Yes"
+msgstr "Taip"
+
+#: ../roundup/cgi/templating.py:1017
+#, python-format
+msgid "%s: (no value)"
+msgstr "%s: (no value)"
+
+#: ../roundup/cgi/templating.py:1029
+msgid ""
+"<strong><em>This event is not handled by the history display!</em></strong>"
+msgstr "<strong><em>Šis įvykis nėra rodomas archyve!</em></strong>"
+
+#: ../roundup/cgi/templating.py:1041
+msgid "<tr><td colspan=4><strong>Note:</strong></td></tr>"
+msgstr "<tr><td colspan=4><strong>Pastaba:</strong></td></tr>"
+
+#: ../roundup/cgi/templating.py:1050
+msgid "History"
+msgstr "Archyvas"
+
+#: ../roundup/cgi/templating.py:1052
+msgid "<th>Date</th>"
+msgstr "<th>Data</th>"
+
+#: ../roundup/cgi/templating.py:1053
+msgid "<th>User</th>"
+msgstr "<th>Vartotojas</th>"
+
+#: ../roundup/cgi/templating.py:1054
+msgid "<th>Action</th>"
+msgstr "<th>Veiksmas</th>"
+
+#: ../roundup/cgi/templating.py:1055
+msgid "<th>Args</th>"
+msgstr "<th>Argumentai</th>"
+
+#: ../roundup/cgi/templating.py:1097
+#, python-format
+msgid "Copy of %(class)s %(id)s"
+msgstr "%(class)s %(id)s kopija"
+
+#: ../roundup/cgi/templating.py:1331
+msgid "*encrypted*"
+msgstr "*užkoduota*"
+
+#: ../roundup/cgi/templating.py:1514
+msgid ""
+"default value for DateHTMLProperty must be either DateHTMLProperty or string "
+"date representation."
+msgstr ""
+"standartinė DateHTMLProperty reikšmė turi būti arba DateHTMLProperty arba "
+"datos reprezentacija kaip simbolių eilutės."
+
+#: ../roundup/cgi/templating.py:1674
+#, python-format
+msgid "Attempt to look up %(attr)s on a missing value"
+msgstr "Bandėte pažiūrėti %(attr)s neegzistuojančiai reikšmei"
+
+#: ../roundup/cgi/templating.py:1750
+#, python-format
+msgid "<option %svalue=\"-1\">- no selection -</option>"
+msgstr "<option %svalue=\"-1\">- nepasirinkta -</option>"
+
+#: ../roundup/date.py:186
+msgid ""
+"Not a date spec: \"yyyy-mm-dd\", \"mm-dd\", \"HH:MM\", \"HH:MM:SS\" or "
+"\"yyyy-mm-dd.HH:MM:SS.SSS\""
+msgstr ""
+"Ne data, nurodykite: „yyyy-mm-dd“, „mm-dd“, „HH:MM“, „HH:MM:SS“ ar „yyyy-mm-"
+"dd.HH:MM:SS.SSS“"
+
+#: ../roundup/date.py:240
+#, python-format
+msgid ""
+"%r not a date / time spec \"yyyy-mm-dd\", \"mm-dd\", \"HH:MM\", \"HH:MM:SS\" "
+"or \"yyyy-mm-dd.HH:MM:SS.SSS\""
+msgstr ""
+"%r ne data / datos formatas „yyyy-mm-dd“, „mm-dd“, „HH:MM“, „HH:MM:SS“ ar "
+"„yyyy-mm-dd.HH:MM:SS.SSS“"
+
+#: ../roundup/date.py:538
+msgid ""
+"Not an interval spec: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS] [date spec]"
+msgstr ""
+"Ne intervalas, nurodyti: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS] [datos "
+"formatas]"
+
+#: ../roundup/date.py:557
+msgid "Not an interval spec: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS]"
+msgstr "Ne intervalas, formatas: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS]"
+
+#: ../roundup/date.py:694
+#, python-format
+msgid "%(number)s year"
+msgid_plural "%(number)s years"
+msgstr[0] "%(number)s metai"
+msgstr[1] "%(number)s metai"
+msgstr[2] "%(number)s metų"
+
+#: ../roundup/date.py:698
+#, python-format
+msgid "%(number)s month"
+msgid_plural "%(number)s months"
+msgstr[0] "%(number)s mÄ—nuo"
+msgstr[1] "%(number)s mÄ—nesiai"
+msgstr[2] "%(number)s mėnesių"
+
+#: ../roundup/date.py:702
+#, python-format
+msgid "%(number)s week"
+msgid_plural "%(number)s weeks"
+msgstr[0] "%(number)s savaitÄ—"
+msgstr[1] "%(number)s savaitÄ—s"
+msgstr[2] "%(number)s savaičių"
+
+#: ../roundup/date.py:706
+#, python-format
+msgid "%(number)s day"
+msgid_plural "%(number)s days"
+msgstr[0] "%(number)s diena"
+msgstr[1] "%(number)s dienos"
+msgstr[2] "%(number)s dienų"
+
+#: ../roundup/date.py:710
+msgid "tomorrow"
+msgstr "rytoj"
+
+#: ../roundup/date.py:712
+msgid "yesterday"
+msgstr "vakar"
+
+#: ../roundup/date.py:715
+#, python-format
+msgid "%(number)s hour"
+msgid_plural "%(number)s hours"
+msgstr[0] "%(number)s valanda"
+msgstr[1] "%(number)s valandos"
+msgstr[2] "%(number)s valandų"
+
+#: ../roundup/date.py:719
+msgid "an hour"
+msgstr "valanda"
+
+#: ../roundup/date.py:721
+msgid "1 1/2 hours"
+msgstr "1 1/2 valandos"
+
+#: ../roundup/date.py:723
+#, python-format
+msgid "1 %(number)s/4 hours"
+msgid_plural "1 %(number)s/4 hours"
+msgstr[0] "1 %(number)s/4 valandos"
+msgstr[1] "1 %(number)s/4 valandos"
+msgstr[2] "1 %(number)s/4 valandos"
+
+#: ../roundup/date.py:727
+msgid "in a moment"
+msgstr "už minutės"
+
+#: ../roundup/date.py:729
+msgid "just now"
+msgstr "kÄ… tik"
+
+#: ../roundup/date.py:732
+msgid "1 minute"
+msgstr "1 minutÄ—"
+
+#: ../roundup/date.py:735
+#, python-format
+msgid "%(number)s minute"
+msgid_plural "%(number)s minutes"
+msgstr[0] "%(number)s minutÄ—"
+msgstr[1] "%(number)s minutÄ—s"
+msgstr[2] "%(number)s minučių"
+
+#: ../roundup/date.py:738
+msgid "1/2 an hour"
+msgstr "1/2 valandos"
+
+#: ../roundup/date.py:740
+#, python-format
+msgid "%(number)s/4 hour"
+msgid_plural "%(number)s/4 hours"
+msgstr[0] "%(number)s/4 valandos"
+msgstr[1] "%(number)s/4 valandos"
+msgstr[2] "%(number)s/4 valandos"
+
+#: ../roundup/date.py:744
+#, python-format
+msgid "%s ago"
+msgstr "prieš %s"
+
+#: ../roundup/date.py:746
+#, python-format
+msgid "in %s"
+msgstr "po %s"
+
+#: ../roundup/init.py:134
+#, python-format
+msgid ""
+"WARNING: directory '%s'\n"
+"\tcontains old-style template - ignored"
+msgstr ""
+"PERSPÄ–JIMAS: direktorijoje '%s'\n"
+"\tseno tipo Å¡ablonas, praleistas"
+
+#: ../roundup/roundupdb.py:141
+msgid "files"
+msgstr "failai"
+
+#: ../roundup/roundupdb.py:141
+msgid "messages"
+msgstr "pranešimai"
+
+#: ../roundup/roundupdb.py:141
+msgid "nosy"
+msgstr "informuoti"
+
+#: ../roundup/roundupdb.py:141
+msgid "superseder"
+msgstr "pirmtakas"
+
+#: ../roundup/roundupdb.py:141
+msgid "title"
+msgstr "antraštė"
+
+#: ../roundup/roundupdb.py:142
+msgid "assignedto"
+msgstr "priskirta"
+
+#: ../roundup/roundupdb.py:142
+msgid "priority"
+msgstr "prioritetas"
+
+#: ../roundup/roundupdb.py:142
+msgid "status"
+msgstr "statusas"
+
+#: ../roundup/roundupdb.py:142
+msgid "topic"
+msgstr "tema"
+
+#: ../roundup/roundupdb.py:145
+msgid "activity"
+msgstr "veiksmas"
+
+#. following properties are common for all hyperdb classes
+#. they are listed here to keep things in one place
+#: ../roundup/roundupdb.py:145
+msgid "actor"
+msgstr "veikÄ—jas"
+
+#: ../roundup/roundupdb.py:145
+msgid "creation"
+msgstr "sukūrimas"
+
+#: ../roundup/roundupdb.py:145
+msgid "creator"
+msgstr "kūrėjas"
+
+#: ../roundup/scripts/roundup_demo.py:32
+#, python-format
+msgid "Enter directory path to create demo tracker [%s]: "
+msgstr "Įveskite kelią į direktoriją demo track'erio sukūrimui [%s]: "
+
+#: ../roundup/scripts/roundup_gettext.py:22
+#, python-format
+msgid "Usage: %(program)s <tracker home>"
+msgstr "Naudojimas: %(program)s <tracker'io namų direktorija>"
+
+#: ../roundup/scripts/roundup_gettext.py:37
+#, python-format
+msgid "No tracker templates found in directory %s"
+msgstr "Direktorijoje %s nėra tracker'io šablonų"
+
+#: ../roundup/scripts/roundup_mailgw.py:36
+#, python-format
+msgid ""
+"Usage: %(program)s [-v] [-c] [[-C class] -S field=value]* <instance home> "
+"[method]\n"
+"\n"
+"Options:\n"
+" -v: print version and exit\n"
+" -c: default class of item to create (else the tracker's "
+"MAIL_DEFAULT_CLASS)\n"
+" -C / -S: see below\n"
+"\n"
+"The roundup mail gateway may be called in one of four ways:\n"
+" . with an instance home as the only argument,\n"
+" . with both an instance home and a mail spool file,\n"
+" . with both an instance home and a POP/APOP server account, or\n"
+" . with both an instance home and a IMAP/IMAPS server account.\n"
+"\n"
+"It also supports optional -C and -S arguments that allows you to set a\n"
+"fields for a class created by the roundup-mailgw. The default class if\n"
+"not specified is msg, but the other classes: issue, file, user can\n"
+"also be used. The -S or --set options uses the same\n"
+"property=value[;property=value] notation accepted by the command line\n"
+"roundup command or the commands that can be given on the Subject line\n"
+"of an email message.\n"
+"\n"
+"It can let you set the type of the message on a per email address basis.\n"
+"\n"
+"PIPE:\n"
+" In the first case, the mail gateway reads a single message from the\n"
+" standard input and submits the message to the roundup.mailgw module.\n"
+"\n"
+"UNIX mailbox:\n"
+" In the second case, the gateway reads all messages from the mail spool\n"
+" file and submits each in turn to the roundup.mailgw module. The file is\n"
+" emptied once all messages have been successfully handled. The file is\n"
+" specified as:\n"
+"   mailbox /path/to/mailbox\n"
+"\n"
+"POP:\n"
+" In the third case, the gateway reads all messages from the POP server\n"
+" specified and submits each in turn to the roundup.mailgw module. The\n"
+" server is specified as:\n"
+"    pop username:password at server\n"
+" The username and password may be omitted:\n"
+"    pop username at server\n"
+"    pop server\n"
+" are both valid. The username and/or password will be prompted for if\n"
+" not supplied on the command-line.\n"
+"\n"
+"APOP:\n"
+" Same as POP, but using Authenticated POP:\n"
+"    apop username:password at server\n"
+"\n"
+"IMAP:\n"
+" Connect to an IMAP server. This supports the same notation as that of\n"
+" POP mail.\n"
+"    imap username:password at server\n"
+" It also allows you to specify a specific mailbox other than INBOX using\n"
+" this format:\n"
+"    imap username:password at server mailbox\n"
+"\n"
+"IMAPS:\n"
+" Connect to an IMAP server over ssl.\n"
+" This supports the same notation as IMAP.\n"
+"    imaps username:password at server [mailbox]\n"
+"\n"
+msgstr ""
+"Naudojimas: %(program)s [-v] [-c] [[-C klasė] -S laukas=reikšmė]* <namų "
+"direktorija> [metodas]\n"
+"\n"
+"Parinktys:\n"
+" -v: išspausdinti vesiją ir baigti\n"
+" -c: stadartinÄ— kuriamo elemento klasÄ— (arba tracker'io MAIL_DEFAULT_CLASS)\n"
+" -C / -S: žiūrėti žemiau\n"
+"\n"
+"Roundup pašto vartai gali būti iškviesti vienu iš keturių būdų:\n"
+" . su namų direktorija kaip vieninteliu argumentu,\n"
+" . su namų direktorija ir pašto spool failu,\n"
+" . su namų direktorija ir POP/APOP serverio abonentu, ar\n"
+" . su namų direktorija ir IMAP/IMAPS serverio abonentu.\n"
+"\n"
+"Ši komanda taipogi palaiko neprivalomus -C ir -S argumentus, kurie leidžia\n"
+"nustatyti laukus klasei, sukurtai roundup-mailgw. StandartinÄ— klasÄ—, jei\n"
+"kitaip nenurodyta yra msg, tačiau kitos klasės: issue, file, user taip pat\n"
+"gali būti naudojamos. -S ar --set parinktys naudoja tą pačią\n"
+"parinktis=reikšmė[;pariktis=reikšmė] notaciją, priimamą roundup komandinės\n"
+"eilutės komandos ar komandų, kurios gali būti duodamos el. pašto Temos\n"
+"eilutÄ—je.\n"
+"\n"
+"Ši komanda leidžia nustatyti pranešimo tipą kiekvienam skirtingam\n"
+"el. pašto adresui.\n"
+"\n"
+"PIPE:\n"
+" Pirmu atveju, pašto vartai nuskaito vieną pranešimą iš standartinės\n"
+" įvesties ir pateikia tą pranešimą roundup.mailgw moduliui.\n"
+"\n"
+"UNIX pašto dėžutė:\n"
+" Antru atveju, vartai nuskaito visus pranešimus iš pašto spool failo\n"
+" ir pateikia kiekvieną iš eilės roundup.mailgw moduliui. Failas yra\n"
+" išvalomas kai visi pranešimai sėkmingai perduodami. Failas nurodomas\n"
+" kaip:\n"
+"   dėžutė /kelias/iki/dėžutės\n"
+"\n"
+"POP:\n"
+" Trečiu atveju, vartai nuskaito visus pranešimus iš nurodyto POP\n"
+" serverio ir pateikia kiekvieną iš eilės roundup.mailgw moduliui.\n"
+" Serveris yra nurodomas kaip:\n"
+"    pop vartotojas:slaptažodis at serveris\n"
+" Vartotojo vardas ir slaptažodis gali būti praleisti:\n"
+"    pop vartotojas at serveris\n"
+"    pop serveris\n"
+" yra tinkami formatai. Vartotojo vardo ar/ir slaptažodžio sistema paprašys,\n"
+" jei jų nepateikėte komandinėje eilutėje.\n"
+"\n"
+"APOP:\n"
+" Taip pat kaip POP, tačiau naudojant Authenticated POP:\n"
+"    apop vartotojas:slaptažodis at serveris\n"
+"\n"
+"IMAP:\n"
+" Prisijungimas prie IMAP serverio. Tai palaiko tą pačią notaciją \n"
+" kaip POP pašto.\n"
+"    imap vartotojas:slaptažodis at serveris\n"
+" Tai taip pat leidžia nurodyti kitą pašto dėžutę nei INBOX naudojant šį\n"
+" formatÄ…:\n"
+"    imap vartotojas:slaptažodis at serveris dėžutė\n"
+"\n"
+"IMAPS:\n"
+" Prisijungimas prie IMAP serverio per ssl.\n"
+" Palaiko tą pačia notaciją kaip IMAP.\n"
+"    imaps vartotojas:slaptažodis at serveris [dėžutė]\n"
+"\n"
+
+#: ../roundup/scripts/roundup_mailgw.py:147
+msgid "Error: not enough source specification information"
+msgstr "Klaida: nepakankamai Å¡altinio specifikacijos informacijos"
+
+#: ../roundup/scripts/roundup_mailgw.py:163
+msgid "Error: pop specification not valid"
+msgstr "Klaida: pop specifikacija netinkama"
+
+#: ../roundup/scripts/roundup_mailgw.py:170
+msgid "Error: apop specification not valid"
+msgstr "Klaida: apop specifikacija netinkama"
+
+#: ../roundup/scripts/roundup_mailgw.py:184
+msgid ""
+"Error: The source must be either \"mailbox\", \"pop\", \"apop\", \"imap\" or "
+"\"imaps\""
+msgstr "Klaida: Šaltinis turi būti „mailbox“, „pop“, „apop“, „imap“ ar „imaps“"
+
+#: ../roundup/scripts/roundup_server.py:157
+msgid ""
+"<html><head><title>Roundup trackers index</title></head>\n"
+"<body><h1>Roundup trackers index</h1><ol>\n"
+msgstr ""
+"<html><head><title>Roundup tracker'io indeksas</title></head>\n"
+"<body><h1>Roundup tracker'io indeksas</h1><ol>\n"
+
+#: ../roundup/scripts/roundup_server.py:287
+#, python-format
+msgid "Error: %s: %s"
+msgstr "Klaida: %s: %s"
+
+#: ../roundup/scripts/roundup_server.py:297
+msgid "WARNING: ignoring \"-g\" argument, not root"
+msgstr "PERSPĖJIMAS: \"-g\" argumentas ignoruojamas, nėra root teisių"
+
+#: ../roundup/scripts/roundup_server.py:303
+msgid "Can't change groups - no grp module"
+msgstr "Negaliu pakeisti grupių -- nėra grp modulio"
+
+#: ../roundup/scripts/roundup_server.py:312
+#, python-format
+msgid "Group %(group)s doesn't exist"
+msgstr "GrupÄ—s %(group)s nÄ—ra"
+
+#: ../roundup/scripts/roundup_server.py:323
+msgid "Can't run as root!"
+msgstr "Negaliu paleisti root teisÄ—mis!"
+
+#: ../roundup/scripts/roundup_server.py:326
+msgid "WARNING: ignoring \"-u\" argument, not root"
+msgstr "PERSPĖJIMAS: \"-u\" argumentas ignoruojamas, nėra root teisių"
+
+#: ../roundup/scripts/roundup_server.py:331
+msgid "Can't change users - no pwd module"
+msgstr "Negaliu pakesiti vartotojų - nėra pwd modulio"
+
+#: ../roundup/scripts/roundup_server.py:340
+#, python-format
+msgid "User %(user)s doesn't exist"
+msgstr "Vartotojo %(user)s nÄ—ra"
+
+#: ../roundup/scripts/roundup_server.py:471
+#, python-format
+msgid "Multiprocess mode \"%s\" is not available, switching to single-process"
+msgstr "Multiprocesinė aplinka \"%s\" neprieinama, perjungiu į vienprocesinę"
+
+#: ../roundup/scripts/roundup_server.py:494
+#, python-format
+msgid "Unable to bind to port %s, port already in use."
+msgstr "Negaliu prijungti prie jungties %s, jungtis jau naudojama."
+
+#: ../roundup/scripts/roundup_server.py:562
+msgid ""
+" -c <Command>  Windows Service options.\n"
+"               If you want to run the server as a Windows Service, you\n"
+"               must use configuration file to specify tracker homes.\n"
+"               Logfile option is required to run Roundup Tracker service.\n"
+"               Typing \"roundup-server -c help\" shows Windows Services\n"
+"               specifics."
+msgstr ""
+" -c <Komanda>  Windows Service parinktys.\n"
+"               Jei norite paleisti serverį kaip Windows Service, turite\n"
+"               naudoti konfigūracijos failą tracker'io namų direktorijoms\n"
+"               nurodyti. Žurnalo failo parinktis būtina, jei norite \n"
+"               paleisti Roundup Tracker servisÄ….\n"
+"               Įvedę \"roundup-server -c help\" pamatysite Windows Services\n"
+"               specifikÄ…."
+
+#: ../roundup/scripts/roundup_server.py:569
+msgid ""
+" -u <UID>      runs the Roundup web server as this UID\n"
+" -g <GID>      runs the Roundup web server as this GID\n"
+" -d <PIDfile>  run the server in the background and write the server's PID\n"
+"               to the file indicated by PIDfile. The -l option *must* be\n"
+"               specified if -d is used."
+msgstr ""
+" -u <UID>      paleidžia Roundup žiniatinklio serverį kaip šis UID\n"
+" -g <GID>      paleidžia Roundup žiniatinklio serverį kaip šis GID\n"
+" -d <PIDfile>  paleidžia serverį fone ir įrašo serverio PID į failą,\n"
+"               nurodytą PIDfaile. Parinktis -l *privalo* būti nurodyta\n"
+"               jei naudojama -d."
+
+#: ../roundup/scripts/roundup_server.py:576
+#, python-format
+msgid ""
+"%(message)sUsage: roundup-server [options] [name=tracker home]*\n"
+"\n"
+"Options:\n"
+" -v            print the Roundup version number and exit\n"
+" -h            print this text and exit\n"
+" -S            create or update configuration file and exit\n"
+" -C <fname>    use configuration file <fname>\n"
+" -n <name>     set the host name of the Roundup web server instance\n"
+" -p <port>     set the port to listen on (default: %(port)s)\n"
+" -l <fname>    log to the file indicated by fname instead of stderr/stdout\n"
+" -N            log client machine names instead of IP addresses (much "
+"slower)\n"
+" -t <mode>     multiprocess mode (default: %(mp_def)s).\n"
+"               Allowed values: %(mp_types)s.\n"
+"%(os_part)s\n"
+"\n"
+"Long options:\n"
+" --version          print the Roundup version number and exit\n"
+" --help             print this text and exit\n"
+" --save-config      create or update configuration file and exit\n"
+" --config <fname>   use configuration file <fname>\n"
+" All settings of the [main] section of the configuration file\n"
+" also may be specified in form --<name>=<value>\n"
+"\n"
+"Examples:\n"
+"\n"
+" roundup-server -S -C /opt/roundup/etc/roundup-server.ini \\\n"
+"    -n localhost -p 8917 -l /var/log/roundup.log \\\n"
+"    support=/var/spool/roundup-trackers/support\n"
+"\n"
+" roundup-server -C /opt/roundup/etc/roundup-server.ini\n"
+"\n"
+" roundup-server support=/var/spool/roundup-trackers/support\n"
+"\n"
+" roundup-server -d /var/run/roundup.pid -l /var/log/roundup.log \\\n"
+"    support=/var/spool/roundup-trackers/support\n"
+"\n"
+"Configuration file format:\n"
+"   Roundup Server configuration file has common .ini file format.\n"
+"   Configuration file created with 'roundup-server -S' contains\n"
+"   detailed explanations for each option.  Please see that file\n"
+"   for option descriptions.\n"
+"\n"
+"How to use \"name=tracker home\":\n"
+"   These arguments set the tracker home(s) to use. The name is how the\n"
+"   tracker is identified in the URL (it's the first part of the URL path).\n"
+"   The tracker home is the directory that was identified when you did\n"
+"   \"roundup-admin init\". You may specify any number of these name=home\n"
+"   pairs on the command-line. Make sure the name part doesn't include\n"
+"   any url-unsafe characters like spaces, as these confuse IE.\n"
+msgstr ""
+"%(message)sNaudojimas: roundup-server [parinktys] [vardas=tracker'io namu "
+"direktorija]*\n"
+"\n"
+"Parinktys:\n"
+" -v            išspausdinti Roundup versijos numerį ir baigti\n"
+" -h            atspausdinti šį tekstą ir baigti\n"
+" -S            sukurti arba atnaujinti konfigūracijos failą ir baigti\n"
+" -C <fvardas>  naudoti konfigūracijos failą <fvardas>\n"
+" -n <vardas>   nustatyti Roundup žiniatinklio serverio kompiuterio vardą\n"
+" -p <jungtis>  nustatyti jungtį stebėjimui (standartinė: %(port)s)\n"
+" -l <fvardas>  registruoti į failą fvardas vietoj stderr/stdout\n"
+" -N            registruoti klientų kompiuterių vardus vietoj IP adresų\n"
+"               (daug lėčiau)\n"
+" -t <veiksena> multiprocesinÄ— veiksena (standartas: %(mp_def)s).\n"
+"               Leidžiamos reikšmės: %(mp_types)s.\n"
+"%(os_part)s\n"
+"\n"
+"Ilgos parinktys:\n"
+" --version          išspausdinti Roundup versijos numerį ir baigti\n"
+" --help             atspausdinti šį tekstą ir baigti\n"
+" --save-config      sukurti arba atnaujinti konfigūracijos failą ir baigti\n"
+" --config <fvardas> naudoti konfigūracijos failą <fvardas>\n"
+" Visi konfigūracijos failo [main] sekcijos nustatymai taip pat gali būti\n"
+" nurodyti kaip --<name>=<value>\n"
+"\n"
+"Pavyzdžiai:\n"
+"\n"
+" roundup-server -S -C /opt/roundup/etc/roundup-server.ini \\\n"
+"    -n localhost -p 8917 -l /var/log/roundup.log \\\n"
+"    support=/var/spool/roundup-trackers/support\n"
+"\n"
+" roundup-server -C /opt/roundup/etc/roundup-server.ini\n"
+"\n"
+" roundup-server support=/var/spool/roundup-trackers/support\n"
+"\n"
+" roundup-server -d /var/run/roundup.pid -l /var/log/roundup.log \\\n"
+"    support=/var/spool/roundup-trackers/support\n"
+"\n"
+"Konfigūracijos failo formatas:\n"
+"   Roundup Serverio konfigūracijos failas turi bendrą .ini failų formatą.\n"
+"   Konfigūracijos failas, kuris sukuriamas naudojant 'roundup-server -S' \n"
+"   komandą, turi detalų paaiškinimą kiekvienai parinkčiai. Naudokitės tuo \n"
+"   failu norėdami peržiūrėti parinkčių aprašymus.\n"
+"\n"
+"Kaip naudoti „vardas=tracker'io_namų_direktorija“:\n"
+"   Šie argumentai nustato tracker'io namų direktoriją/as naudojimui.\n"
+"   Vardas tai tracker'io identifikacija URL (pirma dalis URL).   \n"
+"   Tracker'io namų direktorija tai direktorija, kuri buvo nurodyta,\n"
+"   kai paleidote „roundup-admin init“. Galite nurodyti bet kokį skaičių\n"
+"   'vardas:namų_direktorija' porų komandinėje eilutėje. Įsitikinkite, kad\n"
+"   varde nėra jokių url-nesaugių simbolių, kaip tarpai, nes IE jų \n"
+"   nesupras.\n"
+"\n"
+
+#: ../roundup/scripts/roundup_server.py:724
+msgid "Instances must be name=home"
+msgstr "Egzempliorius turi būti nurodomas taip: vardas=namų_direktorija"
+
+#: ../roundup/scripts/roundup_server.py:738
+#, python-format
+msgid "Configuration saved to %s"
+msgstr "Konfigūracija išsaugota %s"
+
+#: ../roundup/scripts/roundup_server.py:756
+msgid "Sorry, you can't run the server as a daemon on this Operating System"
+msgstr ""
+"JÅ«s negalite paleisti serverio kaip daemon'o Å¡ioje operacinÄ—je sistemoje"
+
+#: ../roundup/scripts/roundup_server.py:768
+#, python-format
+msgid "Roundup server started on %(HOST)s:%(PORT)s"
+msgstr "Roundup serveris paleistas ant %(HOST)s:%(PORT)s"
+
+#: ../templates/classic/html/_generic.collision.html:4
+#: ../templates/minimal/html/_generic.collision.html:4
+msgid "${class} Edit Collision - ${tracker}"
+msgstr "${class} Redagavimo kolizija - ${tracker}"
+
+#: ../templates/classic/html/_generic.collision.html:7
+#: ../templates/minimal/html/_generic.collision.html:7
+msgid "${class} Edit Collision"
+msgstr "${class} Redagavimo kolizija"
+
+#: ../templates/classic/html/_generic.collision.html:14
+#: ../templates/minimal/html/_generic.collision.html:14
+msgid ""
+"\n"
+"  There has been a collision. Another user updated this node\n"
+"  while you were editing. Please <a href='${context}'>reload</a>\n"
+"  the node and review your edits.\n"
+msgstr ""
+"\n"
+"  Įvyko kolizija. Kitas vartotojas atnaujino šį elementą\n"
+"  kol jūs redagavote. <a href='${context}'>Perkraukite</a>\n"
+"  elementą ir peržiūrėkite savo pakeitimus.\n"
+
+#: ../templates/classic/html/_generic.help.html:9
+#: ../templates/minimal/html/_generic.help.html:9
+msgid "${property} help - ${tracker}"
+msgstr "${property} pagalba - ${tracker}"
+
+#: ../templates/classic/html/_generic.help.html:31
+#: ../templates/minimal/html/_generic.help.html:31
+msgid " Cancel "
+msgstr " Atšaukti "
+
+#: ../templates/classic/html/_generic.help.html:34
+#: ../templates/minimal/html/_generic.help.html:34
+msgid " Apply "
+msgstr " Vykdyti "
+
+#: ../templates/classic/html/_generic.help.html:41
+#: ../templates/classic/html/issue.index.html:73
+#: ../templates/minimal/html/_generic.help.html:41
+msgid "&lt;&lt; previous"
+msgstr "&lt;&lt; pirmesnis"
+
+#: ../templates/classic/html/_generic.help.html:52
+#: ../templates/classic/html/issue.index.html:81
+#: ../templates/minimal/html/_generic.help.html:52
+msgid "${start}..${end} out of ${total}"
+msgstr "${start}..${end} iš ${total}"
+
+#: ../templates/classic/html/_generic.help.html:56
+#: ../templates/classic/html/issue.index.html:84
+#: ../templates/minimal/html/_generic.help.html:56
+msgid "next &gt;&gt;"
+msgstr "kitas &gt;&gt;"
+
+#: ../templates/classic/html/_generic.index.html:6
+#: ../templates/classic/html/_generic.item.html:4
+#: ../templates/minimal/html/_generic.index.html:6
+#: ../templates/minimal/html/_generic.item.html:4
+msgid "${class} editing - ${tracker}"
+msgstr "${class} redagavimas - ${tracker}"
+
+#: ../templates/classic/html/_generic.index.html:9
+#: ../templates/classic/html/_generic.item.html:7
+#: ../templates/minimal/html/_generic.index.html:9
+#: ../templates/minimal/html/_generic.item.html:7
+msgid "${class} editing"
+msgstr "${class} redagavimas"
+
+#: ../templates/classic/html/_generic.index.html:14
+#: ../templates/classic/html/_generic.item.html:12
+#: ../templates/classic/html/file.item.html:9
+#: ../templates/classic/html/issue.index.html:16
+#: ../templates/classic/html/issue.item.html:28
+#: ../templates/classic/html/msg.item.html:26
+#: ../templates/classic/html/user.index.html:9
+#: ../templates/classic/html/user.item.html:28
+#: ../templates/minimal/html/_generic.index.html:14
+#: ../templates/minimal/html/_generic.item.html:12
+#: ../templates/minimal/html/user.index.html:9
+#: ../templates/minimal/html/user.item.html:28
+#: ../templates/minimal/html/user.register.html:14
+msgid "You are not allowed to view this page."
+msgstr "Jūs neturite teisių peržiūrėti šį puslapį."
+
+#: ../templates/classic/html/_generic.index.html:22
+#: ../templates/minimal/html/_generic.index.html:22
+msgid ""
+"<p class=\"form-help\"> You may edit the contents of the ${classname} class "
+"using this form. Commas, newlines and double quotes (\") must be handled "
+"delicately. You may include commas and newlines by enclosing the values in "
+"double-quotes (\"). Double quotes themselves must be quoted by doubling "
+"(\"\"). </p> <p class=\"form-help\"> Multilink properties have their "
+"multiple values colon (\":\") separated (... ,\"one:two:three\", ...) </p> "
+"<p class=\"form-help\"> Remove entries by deleting their line. Add new "
+"entries by appending them to the table - put an X in the id column. </p>"
+msgstr ""
+"<p class=\"form-help\"> Jūs galite redaguoti ${classname} klasės turinį "
+"naudodami Å¡iÄ… formÄ…. Su kableliais, naujomis eilutÄ—mis ir dvigubomis "
+"kabutėmis (\") turi būti elgiamasi atsargiai. Jūs galite naudoti kablelius "
+"ir naujas eilutes užkomentuodami reikšmes dvigubomis kabutėmis (\"). "
+"Dvigubos kabutės turi būti užkomentuotos dvigubinant (\"\"). </p> <p class="
+"\"form-help\"> Daugiasąsajėse parinktyse reikšmės atskirtos dvitaškiu (\":"
+"\") (... ,\"one:two:three\", ...) </p> <p class=\"form-help\"> Įrašai "
+"ištrinami ištrinant jų eilutę. Nauji įrašai pridedami prijungiant juos prie "
+"lentelės - įrašykite X id stulpelyje. </p>"
+
+#: ../templates/classic/html/_generic.index.html:44
+#: ../templates/minimal/html/_generic.index.html:44
+msgid "Edit Items"
+msgstr "Redaguoti elementus"
+
+#: ../templates/classic/html/file.index.html:4
+msgid "List of files - ${tracker}"
+msgstr "Failų sąrašas - ${tracker}"
+
+#: ../templates/classic/html/file.index.html:5
+msgid "List of files"
+msgstr "Failų sąrašas"
+
+#: ../templates/classic/html/file.index.html:10
+msgid "Download"
+msgstr "Parsisiųsti"
+
+#: ../templates/classic/html/file.index.html:11
+#: ../templates/classic/html/file.item.html:22
+msgid "Content Type"
+msgstr "Turinio tipas"
+
+#: ../templates/classic/html/file.index.html:12
+msgid "Uploaded By"
+msgstr "Nusiųsta iki"
+
+#: ../templates/classic/html/file.index.html:13
+#: ../templates/classic/html/msg.item.html:43
+msgid "Date"
+msgstr "Data"
+
+#: ../templates/classic/html/file.item.html:2
+msgid "File display - ${tracker}"
+msgstr "Failų pateikimas - ${tracker}"
+
+#: ../templates/classic/html/file.item.html:4
+msgid "File display"
+msgstr "Failų pateikimas"
+
+#: ../templates/classic/html/file.item.html:18
+#: ../templates/classic/html/user.item.html:39
+#: ../templates/classic/html/user.register.html:17
+msgid "Name"
+msgstr "Vardas"
+
+#: ../templates/classic/html/file.item.html:40
+msgid "download"
+msgstr "parsisiųsti"
+
+#: ../templates/classic/html/home.classlist.html:2
+#: ../templates/minimal/html/home.classlist.html:2
+msgid "List of classes - ${tracker}"
+msgstr "Klasių sąrašas - ${tracker}"
+
+#: ../templates/classic/html/home.classlist.html:4
+#: ../templates/minimal/html/home.classlist.html:4
+msgid "List of classes"
+msgstr "Klasių sąrašas"
+
+#: ../templates/classic/html/issue.index.html:7
+msgid "List of issues - ${tracker}"
+msgstr "Kreipinių sąrašas - ${tracker}"
+
+#: ../templates/classic/html/issue.index.html:11
+msgid "List of issues"
+msgstr "Kreipinių sąrašas"
+
+#: ../templates/classic/html/issue.index.html:22
+#: ../templates/classic/html/issue.item.html:44
+msgid "Priority"
+msgstr "Prioritetas"
+
+#: ../templates/classic/html/issue.index.html:23
+msgid "ID"
+msgstr "ID"
+
+#: ../templates/classic/html/issue.index.html:24
+msgid "Creation"
+msgstr "Sukūrimas"
+
+#: ../templates/classic/html/issue.index.html:25
+msgid "Activity"
+msgstr "Veikla"
+
+#: ../templates/classic/html/issue.index.html:26
+msgid "Actor"
+msgstr "VeikÄ—jas"
+
+#: ../templates/classic/html/issue.index.html:27
+msgid "Topic"
+msgstr "Tema"
+
+#: ../templates/classic/html/issue.index.html:28
+#: ../templates/classic/html/issue.item.html:39
+msgid "Title"
+msgstr "Pavadinimas"
+
+#: ../templates/classic/html/issue.index.html:29
+#: ../templates/classic/html/issue.item.html:46
+msgid "Status"
+msgstr "Statusas"
+
+#: ../templates/classic/html/issue.index.html:30
+msgid "Creator"
+msgstr "Sukūrėjas"
+
+#: ../templates/classic/html/issue.index.html:31
+msgid "Assigned&nbsp;To"
+msgstr "Priskirta"
+
+#: ../templates/classic/html/issue.index.html:97
+msgid "Download as CSV"
+msgstr "Parsisiųsti kaip CSV"
+
+#: ../templates/classic/html/issue.index.html:105
+msgid "Sort on:"
+msgstr "Išrūšiuoti pagal:"
+
+#: ../templates/classic/html/issue.index.html:108
+#: ../templates/classic/html/issue.index.html:125
+msgid "- nothing -"
+msgstr "- nieko -"
+
+#: ../templates/classic/html/issue.index.html:116
+#: ../templates/classic/html/issue.index.html:133
+msgid "Descending:"
+msgstr "Mažėjančia tvarka:"
+
+#: ../templates/classic/html/issue.index.html:122
+msgid "Group on:"
+msgstr "Grupuoti pagal:"
+
+#: ../templates/classic/html/issue.index.html:139
+msgid "Redisplay"
+msgstr "Perpaišyti vaizdą"
+
+#: ../templates/classic/html/issue.item.html:7
+msgid "Issue ${id}: ${title} - ${tracker}"
+msgstr "Kreipinys ${id}: ${title} - ${tracker}"
+
+#: ../templates/classic/html/issue.item.html:10
+msgid "New Issue - ${tracker}"
+msgstr "Naujas kreipinys - ${tracker}"
+
+#: ../templates/classic/html/issue.item.html:14
+msgid "New Issue"
+msgstr "Naujas kreipinys"
+
+#: ../templates/classic/html/issue.item.html:16
+msgid "New Issue Editing"
+msgstr "Naujo kreipinio redagavimas"
+
+#: ../templates/classic/html/issue.item.html:19
+msgid "Issue${id}"
+msgstr "Kreipinys${id}"
+
+#: ../templates/classic/html/issue.item.html:22
+msgid "Issue${id} Editing"
+msgstr "Kreipinio${id} redagavimas"
+
+#: ../templates/classic/html/issue.item.html:51
+msgid "Superseder"
+msgstr "Pirmtakas"
+
+#: ../templates/classic/html/issue.item.html:56
+msgid "View: ${link}"
+msgstr "Žiūrėti: ${link}"
+
+#: ../templates/classic/html/issue.item.html:60
+msgid "Nosy List"
+msgstr "Sąrašas informuoti"
+
+#: ../templates/classic/html/issue.item.html:69
+msgid "Assigned To"
+msgstr "Priskirta"
+
+#: ../templates/classic/html/issue.item.html:71
+msgid "Topics"
+msgstr "Temos"
+
+#: ../templates/classic/html/issue.item.html:79
+msgid "Change Note"
+msgstr "Pakeitimų pastabos"
+
+#: ../templates/classic/html/issue.item.html:87
+msgid "File"
+msgstr "Failas"
+
+#: ../templates/classic/html/issue.item.html:99
+msgid "Make a copy"
+msgstr "Kopijuoti"
+
+#: ../templates/classic/html/issue.item.html:107
+#: ../templates/classic/html/user.item.html:106
+#: ../templates/classic/html/user.register.html:69
+#: ../templates/minimal/html/user.item.html:86
+msgid ""
+"<table class=\"form\"> <tr> <td>Note:&nbsp;</td> <th class=\"required"
+"\">highlighted</th> <td>&nbsp;fields are required.</td> </tr> </table>"
+msgstr ""
+"<table class=\"form\"> <tr> <td>Pastaba:&nbsp;</td> <th class=\"required"
+"\">pažymėti</th> <td>&nbsp;laukai yra privalomi.</td> </tr> </table>"
+
+#: ../templates/classic/html/issue.item.html:121
+msgid ""
+"Created on <b>${creation}</b> by <b>${creator}</b>, last changed <b>"
+"${activity}</b> by <b>${actor}</b>."
+msgstr ""
+"Sukurta <b>${creation}</b> <b>${creator}</b>, paskutinį kartą keista <b>"
+"${activity}</b> <b>${actor}</b>."
+
+#: ../templates/classic/html/issue.item.html:125
+#: ../templates/classic/html/msg.item.html:56
+msgid "Files"
+msgstr "Failai"
+
+#: ../templates/classic/html/issue.item.html:127
+#: ../templates/classic/html/msg.item.html:58
+msgid "File name"
+msgstr "Failo vardas"
+
+#: ../templates/classic/html/issue.item.html:128
+#: ../templates/classic/html/msg.item.html:59
+msgid "Uploaded"
+msgstr "Nusiųsta"
+
+#: ../templates/classic/html/issue.item.html:129
+msgid "Type"
+msgstr "Tipas"
+
+#: ../templates/classic/html/issue.item.html:130
+#: ../templates/classic/html/query.edit.html:30
+msgid "Edit"
+msgstr "Redaguoti"
+
+#: ../templates/classic/html/issue.item.html:131
+msgid "Remove"
+msgstr "Pašalinti"
+
+#: ../templates/classic/html/issue.item.html:151
+#: ../templates/classic/html/issue.item.html:172
+#: ../templates/classic/html/query.edit.html:50
+msgid "remove"
+msgstr "pašalinti"
+
+#: ../templates/classic/html/issue.item.html:158
+#: ../templates/classic/html/msg.index.html:9
+msgid "Messages"
+msgstr "Pranešimai"
+
+#: ../templates/classic/html/issue.item.html:162
+msgid "msg${id} (view)"
+msgstr "msg${id} (view)"
+
+#: ../templates/classic/html/issue.item.html:163
+msgid "Author: ${author}"
+msgstr "Autorius: ${author}"
+
+#: ../templates/classic/html/issue.item.html:165
+msgid "Date: ${date}"
+msgstr "Data: ${date}"
+
+#: ../templates/classic/html/issue.search.html:2
+msgid "Issue searching - ${tracker}"
+msgstr "Kreipinių paieška - ${tracker}"
+
+#: ../templates/classic/html/issue.search.html:4
+msgid "Issue searching"
+msgstr "Kreipinių paieška"
+
+#: ../templates/classic/html/issue.search.html:25
+msgid "Filter on"
+msgstr "Filtruoti pagal"
+
+#: ../templates/classic/html/issue.search.html:26
+msgid "Display"
+msgstr "Parodyti"
+
+#: ../templates/classic/html/issue.search.html:27
+msgid "Sort on"
+msgstr "RÅ«Å¡iuoti pagal"
+
+#: ../templates/classic/html/issue.search.html:28
+msgid "Group on"
+msgstr "Grupuoti pagal"
+
+#: ../templates/classic/html/issue.search.html:32
+msgid "All text*:"
+msgstr "Visas tekstas*:"
+
+#: ../templates/classic/html/issue.search.html:40
+msgid "Title:"
+msgstr "Pavadinimas:"
+
+#: ../templates/classic/html/issue.search.html:50
+msgid "Topic:"
+msgstr "Tema:"
+
+#: ../templates/classic/html/issue.search.html:58
+msgid "ID:"
+msgstr "ID:"
+
+#: ../templates/classic/html/issue.search.html:66
+msgid "Creation Date:"
+msgstr "Sukūrimo data:"
+
+#: ../templates/classic/html/issue.search.html:77
+msgid "Creator:"
+msgstr "KÅ«rÄ—jas:"
+
+#: ../templates/classic/html/issue.search.html:79
+msgid "created by me"
+msgstr "mano sukurta"
+
+#: ../templates/classic/html/issue.search.html:88
+msgid "Activity:"
+msgstr "Veikla:"
+
+#: ../templates/classic/html/issue.search.html:99
+msgid "Actor:"
+msgstr "VeikÄ—jas:"
+
+#: ../templates/classic/html/issue.search.html:101
+msgid "done by me"
+msgstr "mano atlikta"
+
+#: ../templates/classic/html/issue.search.html:112
+msgid "Priority:"
+msgstr "Prioritetas:"
+
+#: ../templates/classic/html/issue.search.html:114
+#: ../templates/classic/html/issue.search.html:130
+msgid "not selected"
+msgstr "nepasirinkta"
+
+#: ../templates/classic/html/issue.search.html:125
+msgid "Status:"
+msgstr "Statusas:"
+
+#: ../templates/classic/html/issue.search.html:128
+msgid "not resolved"
+msgstr "neišspręsta"
+
+#: ../templates/classic/html/issue.search.html:143
+msgid "Assigned to:"
+msgstr "Priskirta:"
+
+#: ../templates/classic/html/issue.search.html:146
+msgid "assigned to me"
+msgstr "priskirta man"
+
+#: ../templates/classic/html/issue.search.html:148
+msgid "unassigned"
+msgstr "nepriskirta"
+
+#: ../templates/classic/html/issue.search.html:158
+msgid "No Sort or group:"
+msgstr "Nėra rūšiavimo ar grupavimo:"
+
+#: ../templates/classic/html/issue.search.html:166
+msgid "Pagesize:"
+msgstr "Puslapio dydis:"
+
+#: ../templates/classic/html/issue.search.html:172
+msgid "Start With:"
+msgstr "PradÄ—ti nuo:"
+
+#: ../templates/classic/html/issue.search.html:178
+msgid "Sort Descending:"
+msgstr "Rūšiuoti mažėjančia tvarka:"
+
+#: ../templates/classic/html/issue.search.html:185
+msgid "Group Descending:"
+msgstr "Grupuoti mažėjančia tvarka:"
+
+#: ../templates/classic/html/issue.search.html:192
+msgid "Query name**:"
+msgstr "Užklausos vardas**:"
+
+#: ../templates/classic/html/issue.search.html:204
+#: ../templates/classic/html/page.html:31
+#: ../templates/classic/html/page.html:60
+#: ../templates/minimal/html/page.html:31
+msgid "Search"
+msgstr "Paieška"
+
+#: ../templates/classic/html/issue.search.html:209
+msgid "*: The \"all text\" field will look in message bodies and issue titles"
+msgstr ""
+"*: Laukas „visas tekstas“ iššoks pranešimų tekste ir kreipinių "
+"pavadinimuose"
+
+#: ../templates/classic/html/issue.search.html:212
+msgid ""
+"**: If you supply a name, the query will be saved off and available as a "
+"link in the sidebar"
+msgstr ""
+"**: Jei jūs pateiksite vardą, užklausa bus išsaugota "
+"ir prieinama kaip nuoroda Å¡oninÄ—je juostoje"
+
+#: ../templates/classic/html/keyword.item.html:3
+msgid "Keyword editing - ${tracker}"
+msgstr "Raktinių žodžių redagavimas - ${tracker}"
+
+#: ../templates/classic/html/keyword.item.html:5
+msgid "Keyword editing"
+msgstr "Raktinių žodžių redagavimas"
+
+#: ../templates/classic/html/keyword.item.html:11
+msgid "Existing Keywords"
+msgstr "Esami raktiniai žodžiai"
+
+#: ../templates/classic/html/keyword.item.html:20
+msgid ""
+"To edit an existing keyword (for spelling or typing errors), click on its "
+"entry above."
+msgstr ""
+"Esamų raktinių žodžių redagavimui (rašybos klaidos ir netikslumai), "
+"spustelkite ant atitinkamo įrašo viršuje."
+
+#: ../templates/classic/html/keyword.item.html:27
+msgid "To create a new keyword, enter it below and click \"Submit New Entry\"."
+msgstr ""
+"Tam, kad sukurtumėte raktinį žodį, įveskite jį apačioje ir paspauskite "
+"„Įvesti naują įrašą“."
+
+#: ../templates/classic/html/keyword.item.html:37
+msgid "Keyword"
+msgstr "Raktinis žodis"
+
+#: ../templates/classic/html/msg.index.html:3
+msgid "List of messages - ${tracker}"
+msgstr "Pranešimų sąrašas - ${tracker}"
+
+#: ../templates/classic/html/msg.index.html:5
+msgid "Message listing"
+msgstr "Pranešimų sąrašas"
+
+#: ../templates/classic/html/msg.item.html:6
+msgid "Message ${id} - ${tracker}"
+msgstr "Pranešimas ${id} - ${tracker}"
+
+#: ../templates/classic/html/msg.item.html:9
+msgid "New Message - ${tracker}"
+msgstr "Naujas pranešimas - ${tracker}"
+
+#: ../templates/classic/html/msg.item.html:13
+msgid "New Message"
+msgstr "Naujas pranešimas"
+
+#: ../templates/classic/html/msg.item.html:15
+msgid "New Message Editing"
+msgstr "Naujo pranešimo redagavimas"
+
+#: ../templates/classic/html/msg.item.html:18
+msgid "Message${id}"
+msgstr "Pranešimas${id}"
+
+#: ../templates/classic/html/msg.item.html:21
+msgid "Message${id} Editing"
+msgstr "Pranešimo${id} redagavimas"
+
+#: ../templates/classic/html/msg.item.html:33
+msgid "Author"
+msgstr "Autorius"
+
+#: ../templates/classic/html/msg.item.html:38
+msgid "Recipients"
+msgstr "GavÄ—jai"
+
+#: ../templates/classic/html/msg.item.html:49
+msgid "Content"
+msgstr "Turinys"
+
+#: ../templates/classic/html/page.html:41
+msgid "<b>Your Queries</b> (<a href=\"query?@template=edit\">edit</a>)"
+msgstr "<b>Jūsų užklausos</b> (<a href=\"query?@template=edit\">redaguoti</a>)"
+
+#: ../templates/classic/html/page.html:52
+msgid "Issues"
+msgstr "Kreipiniai"
+
+#: ../templates/classic/html/page.html:54
+#: ../templates/classic/html/page.html:74
+msgid "Create New"
+msgstr "Sukurti naujÄ…"
+
+#: ../templates/classic/html/page.html:56
+msgid "Show Unassigned"
+msgstr "Rodyti nepriskirtus"
+
+#: ../templates/classic/html/page.html:58
+msgid "Show All"
+msgstr "Rodyti visus"
+
+#: ../templates/classic/html/page.html:61
+msgid "Show issue:"
+msgstr "Rodyti kreipinį:"
+
+#: ../templates/classic/html/page.html:72
+msgid "Keywords"
+msgstr "Raktiniai žodžiai"
+
+#: ../templates/classic/html/page.html:78
+msgid "Edit Existing"
+msgstr "Redaguoti esamus"
+
+#: ../templates/classic/html/page.html:84
+#: ../templates/minimal/html/page.html:65
+msgid "Administration"
+msgstr "Administravimas"
+
+#: ../templates/classic/html/page.html:86
+#: ../templates/minimal/html/page.html:66
+msgid "Class List"
+msgstr "Klasių sąrašas"
+
+#: ../templates/classic/html/page.html:90
+#: ../templates/minimal/html/page.html:68
+msgid "User List"
+msgstr "Vartotojų sąrašas"
+
+#: ../templates/classic/html/page.html:92
+#: ../templates/minimal/html/page.html:71
+msgid "Add User"
+msgstr "PridÄ—ti vartotojÄ…"
+
+#: ../templates/classic/html/page.html:99
+#: ../templates/classic/html/page.html:105
+#: ../templates/minimal/html/page.html:46
+msgid "Login"
+msgstr "Prisijungti"
+
+#: ../templates/classic/html/page.html:104
+#: ../templates/minimal/html/page.html:45
+msgid "Remember me?"
+msgstr "Prisiminti mane?"
+
+#: ../templates/classic/html/page.html:108
+#: ../templates/classic/html/user.register.html:63
+#: ../templates/minimal/html/page.html:50
+#: ../templates/minimal/html/user.register.html:58
+msgid "Register"
+msgstr "Užsiregistruoti"
+
+#: ../templates/classic/html/page.html:111
+msgid "Lost&nbsp;your&nbsp;login?"
+msgstr "Pamiršote&nbsp;savo&nbsp;vartotojo&nbsp;vardą?"
+
+#: ../templates/classic/html/page.html:116
+msgid "Hello, ${user}"
+msgstr "Sveiki, ${user}"
+
+#: ../templates/classic/html/page.html:118
+msgid "Your Issues"
+msgstr "Jūsų kreipiniai"
+
+#: ../templates/classic/html/page.html:119
+#: ../templates/minimal/html/page.html:57
+msgid "Your Details"
+msgstr "SmulkesnÄ— informacija apie jus"
+
+#: ../templates/classic/html/page.html:121
+#: ../templates/minimal/html/page.html:59
+msgid "Logout"
+msgstr "Atsijungti"
+
+#: ../templates/classic/html/page.html:125
+msgid "Help"
+msgstr "Pagalba"
+
+#: ../templates/classic/html/page.html:126
+msgid "Roundup docs"
+msgstr "Roundup dokumentacija"
+
+#: ../templates/classic/html/page.html:136
+#: ../templates/minimal/html/page.html:81
+msgid "clear this message"
+msgstr "išvalyti šį pranešimą"
+
+#: ../templates/classic/html/page.html:181
+msgid "don't care"
+msgstr "nesvarbu"
+
+#: ../templates/classic/html/page.html:183
+msgid "------------"
+msgstr "------------"
+
+#: ../templates/classic/html/page.html:210
+msgid "no value"
+msgstr "nėra reikšmės"
+
+#: ../templates/classic/html/query.edit.html:4
+msgid "\"Your Queries\" Editing - ${tracker}"
+msgstr "„Jūsų užklausos“ redagavimas - ${tracker}"
+
+#: ../templates/classic/html/query.edit.html:6
+msgid "\"Your Queries\" Editing"
+msgstr "„Jūsų užklausos“ redagavimas"
+
+#: ../templates/classic/html/query.edit.html:11
+msgid "You are not allowed to edit queries."
+msgstr "Jūs neturite teisių redaguoti užklausas."
+
+#: ../templates/classic/html/query.edit.html:28
+msgid "Query"
+msgstr "Užklausa"
+
+#: ../templates/classic/html/query.edit.html:29
+msgid "Include in \"Your Queries\""
+msgstr "Įterpkite į „Jūsų užklausos“"
+
+#: ../templates/classic/html/query.edit.html:31
+msgid "Private to you?"
+msgstr "Privatus?"
+
+#: ../templates/classic/html/query.edit.html:44
+msgid "leave out"
+msgstr "neįtraukti"
+
+#: ../templates/classic/html/query.edit.html:45
+msgid "include"
+msgstr "įtraukti"
+
+#: ../templates/classic/html/query.edit.html:49
+msgid "leave in"
+msgstr "palikti įtrauktą"
+
+#: ../templates/classic/html/query.edit.html:54
+msgid "[query is retired]"
+msgstr "[užklausa deaktyvuota]"
+
+#: ../templates/classic/html/query.edit.html:67
+#: ../templates/classic/html/query.edit.html:92
+msgid "edit"
+msgstr "redaguoti"
+
+#: ../templates/classic/html/query.edit.html:71
+msgid "yes"
+msgstr "taip"
+
+#: ../templates/classic/html/query.edit.html:73
+msgid "no"
+msgstr "ne"
+
+#: ../templates/classic/html/query.edit.html:79
+msgid "Delete"
+msgstr "IÅ¡trinti"
+
+#: ../templates/classic/html/query.edit.html:94
+msgid "[not yours to edit]"
+msgstr "[negalite redaguoti]"
+
+#: ../templates/classic/html/query.edit.html:102
+msgid "Save Selection"
+msgstr "IÅ¡saugoti atrankÄ…"
+
+#: ../templates/classic/html/user.forgotten.html:3
+msgid "Password reset request - ${tracker}"
+msgstr "Slaptažodžio atstatymo užklausa - ${tracker}"
+
+#: ../templates/classic/html/user.forgotten.html:5
+msgid "Password reset request"
+msgstr "Slaptažodžio atstatymo užklausa"
+
+#: ../templates/classic/html/user.forgotten.html:9
+msgid ""
+"You have two options if you have forgotten your password. If you know the "
+"email address you registered with, enter it below."
+msgstr ""
+"Jūs turite du pasirinkimus jei pamiršote savo slaptažodį. Jei žinote el. "
+"pašto adresą, kuriuo prisiregistravote, įveskite jį žemiau."
+
+#: ../templates/classic/html/user.forgotten.html:16
+msgid "Email Address:"
+msgstr "El. pašto adresas"
+
+#: ../templates/classic/html/user.forgotten.html:24
+#: ../templates/classic/html/user.forgotten.html:34
+msgid "Request password reset"
+msgstr "Prašyti slaptažodžio atstatymo"
+
+#: ../templates/classic/html/user.forgotten.html:30
+msgid "Or, if you know your username, then enter it below."
+msgstr "Arba, jei žinote vartotojo vardą, įveskite jį žemiau."
+
+#: ../templates/classic/html/user.forgotten.html:33
+msgid "Username:"
+msgstr "Vartotojo vardas:"
+
+#: ../templates/classic/html/user.forgotten.html:39
+msgid ""
+"A confirmation email will be sent to you - please follow the instructions "
+"within it to complete the reset process."
+msgstr ""
+"Jums bus išsiųstas patvirtinimo el. laiškas - sekite jame duotas "
+"instrukcijas, kad pabaigtumÄ—te atstatymo procesÄ… "
+
+#: ../templates/classic/html/user.index.html:3
+#: ../templates/minimal/html/user.index.html:3
+msgid "User listing - ${tracker}"
+msgstr "Vartotojų sąrašas - ${tracker}"
+
+#: ../templates/classic/html/user.index.html:5
+#: ../templates/minimal/html/user.index.html:5
+msgid "User listing"
+msgstr "Vartotojų sąrašas"
+
+#: ../templates/classic/html/user.index.html:14
+#: ../templates/minimal/html/user.index.html:14
+msgid "Username"
+msgstr "Vartotojo vardas"
+
+#: ../templates/classic/html/user.index.html:15
+msgid "Real name"
+msgstr "Tikras vardas"
+
+#: ../templates/classic/html/user.index.html:16
+#: ../templates/classic/html/user.item.html:70
+#: ../templates/classic/html/user.register.html:45
+msgid "Organisation"
+msgstr "Organizacija"
+
+#: ../templates/classic/html/user.index.html:17
+#: ../templates/minimal/html/user.index.html:15
+msgid "Email address"
+msgstr "El. pašto adresas"
+
+#: ../templates/classic/html/user.index.html:18
+msgid "Phone number"
+msgstr "Telefono numeris"
+
+#: ../templates/classic/html/user.index.html:19
+msgid "Retire"
+msgstr "Deaktyvuoti"
+
+#: ../templates/classic/html/user.index.html:32
+msgid "retire"
+msgstr "deaktyvuoti"
+
+#: ../templates/classic/html/user.item.html:7
+#: ../templates/minimal/html/user.item.html:7
+msgid "User ${id}: ${title} - ${tracker}"
+msgstr "Vartotojas ${id}: ${title} - ${tracker}"
+
+#: ../templates/classic/html/user.item.html:10
+#: ../templates/minimal/html/user.item.html:10
+msgid "New User - ${tracker}"
+msgstr "Naujas vartotojas - ${tracker}"
+
+#: ../templates/classic/html/user.item.html:14
+#: ../templates/minimal/html/user.item.html:14
+msgid "New User"
+msgstr "Naujas vartotojas"
+
+#: ../templates/classic/html/user.item.html:16
+#: ../templates/minimal/html/user.item.html:16
+msgid "New User Editing"
+msgstr "Naujo vartotojo redagavimas"
+
+#: ../templates/classic/html/user.item.html:19
+#: ../templates/minimal/html/user.item.html:19
+msgid "User${id}"
+msgstr "Vartotojas${id}"
+
+#: ../templates/classic/html/user.item.html:22
+#: ../templates/minimal/html/user.item.html:22
+msgid "User${id} Editing"
+msgstr "Vartotojo${id} redagavimas"
+
+#: ../templates/classic/html/user.item.html:43
+#: ../templates/classic/html/user.register.html:21
+#: ../templates/minimal/html/user.item.html:40
+#: ../templates/minimal/html/user.register.html:26
+msgid "Login Name"
+msgstr "Prisijungimo vardas"
+
+#: ../templates/classic/html/user.item.html:47
+#: ../templates/classic/html/user.register.html:25
+#: ../templates/minimal/html/user.item.html:44
+#: ../templates/minimal/html/user.register.html:30
+msgid "Login Password"
+msgstr "Prisijungimo slaptažodis"
+
+#: ../templates/classic/html/user.item.html:51
+#: ../templates/classic/html/user.register.html:29
+#: ../templates/minimal/html/user.item.html:48
+#: ../templates/minimal/html/user.register.html:34
+msgid "Confirm Password"
+msgstr "Patvirtinti slaptažodį"
+
+#: ../templates/classic/html/user.item.html:55
+#: ../templates/classic/html/user.register.html:33
+#: ../templates/minimal/html/user.item.html:52
+#: ../templates/minimal/html/user.register.html:38
+msgid "Roles"
+msgstr "RolÄ—s"
+
+#: ../templates/classic/html/user.item.html:61
+#: ../templates/minimal/html/user.item.html:58
+msgid "(to give the user more than one role, enter a comma,separated,list)"
+msgstr ""
+"(jei norite vartotojui priskirti daugiau nei vieną rolę, įveskite kableliais,"
+"atskirtą,sąrašą)"
+
+#: ../templates/classic/html/user.item.html:66
+#: ../templates/classic/html/user.register.html:41
+msgid "Phone"
+msgstr "Telefonas"
+
+#: ../templates/classic/html/user.item.html:74
+msgid "Timezone"
+msgstr "Laiko zona"
+
+#: ../templates/classic/html/user.item.html:78
+msgid "(this is a numeric hour offset, the default is ${zone})"
+msgstr "(tai yra skaitinis valandų poslinkis, standartas yra ${zone})"
+
+#: ../templates/classic/html/user.item.html:83
+#: ../templates/classic/html/user.register.html:49
+#: ../templates/minimal/html/user.item.html:63
+#: ../templates/minimal/html/user.register.html:46
+msgid "E-mail address"
+msgstr "El. pašto adresas"
+
+#: ../templates/classic/html/user.item.html:91
+#: ../templates/classic/html/user.register.html:53
+#: ../templates/minimal/html/user.item.html:71
+#: ../templates/minimal/html/user.register.html:50
+msgid "Alternate E-mail addresses<br>One address per line"
+msgstr "Alternatyvūs el. pašto adresai<br>Vienas adresas eilutėje"
+
+#: ../templates/classic/html/user.register.html:4
+#: ../templates/classic/html/user.register.html:7
+#: ../templates/minimal/html/user.register.html:4
+#: ../templates/minimal/html/user.register.html:7
+msgid "Registering with ${tracker}"
+msgstr "Registruotis į ${tracker}"
+
+#: ../templates/classic/html/user.rego_progress.html:4
+#: ../templates/minimal/html/user.rego_progress.html:4
+msgid "Registration in progress - ${tracker}"
+msgstr "Vyksta registracija - ${tracker}"
+
+#: ../templates/classic/html/user.rego_progress.html:6
+#: ../templates/minimal/html/user.rego_progress.html:6
+msgid "Registration in progress..."
+msgstr "Vyksta registracija..."
+
+#: ../templates/classic/html/user.rego_progress.html:10
+#: ../templates/minimal/html/user.rego_progress.html:10
+msgid ""
+"You will shortly receive an email to confirm your registration. To complete "
+"the registration process, visit the link indicated in the email."
+msgstr ""
+"Netrukus jūs gausite el. laišką su jūsų registracijos patvirtinimu. kad "
+"pabaigtumėte registracijoc procesą, sekite nuorodą atsiųstame laiške."
+
+#: ../templates/minimal/html/home.html:2
+msgid "Tracker home - ${tracker}"
+msgstr "Tracker'io namų direktorija - ${tracker}"
+
+#: ../templates/minimal/html/home.html:4
+msgid "Tracker home"
+msgstr "Tracker'io namų direktorija"
+
+#: ../templates/minimal/html/home.html:16
+msgid "Please select from one of the menu options on the left."
+msgstr "Pasirinkite vieną iš parinkčių iš meniu kairėje."
+
+#: ../templates/minimal/html/home.html:19
+msgid "Please log in or register."
+msgstr "Prisijunkite ar užsiregistruokite."
+
+#: ../templates/minimal/html/page.html:55
+msgid "Hello,<br>${user}"
+msgstr "Sveiki,<br>${user}"

Added: tracker/vendor/roundup/current/locale/roundup.pot
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/locale/roundup.pot	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,2562 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR See Roundup README.txt
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR <EMAIL at ADDRESS>, YEAR.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\n"
+"Report-Msgid-Bugs-To: roundup-devel at lists.sourceforge.net\n"
+"POT-Creation-Date: 2006-02-28 07:44+0200\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL at ADDRESS>\n"
+"Language-Team: LANGUAGE <LL at li.org>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=CHARSET\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n"
+
+# ../roundup/admin.py:85 :979 :1028 :1050
+#: ../roundup/admin.py:85 ../roundup/admin.py:979 ../roundup/admin.py:1028
+#: ../roundup/admin.py:1050
+#, python-format
+msgid "no such class \"%(classname)s\""
+msgstr ""
+
+# ../roundup/admin.py:95 :99
+#: ../roundup/admin.py:95 ../roundup/admin.py:99
+#, python-format
+msgid "argument \"%(arg)s\" not propname=value"
+msgstr ""
+
+#: ../roundup/admin.py:112
+#, python-format
+msgid ""
+"Problem: %(message)s\n"
+"\n"
+msgstr ""
+
+#: ../roundup/admin.py:113
+#, python-format
+msgid ""
+"%(message)sUsage: roundup-admin [options] [<command> <arguments>]\n"
+"\n"
+"Options:\n"
+" -i instance home  -- specify the issue tracker \"home directory\" to "
+"administer\n"
+" -u                -- the user[:password] to use for commands\n"
+" -d                -- print full designators not just class id numbers\n"
+" -c                -- when outputting lists of data, comma-separate them.\n"
+"                      Same as '-S \",\"'.\n"
+" -S <string>       -- when outputting lists of data, string-separate them\n"
+" -s                -- when outputting lists of data, space-separate them.\n"
+"                      Same as '-S \" \"'.\n"
+"\n"
+" Only one of -s, -c or -S can be specified.\n"
+"\n"
+"Help:\n"
+" roundup-admin -h\n"
+" roundup-admin help                       -- this help\n"
+" roundup-admin help <command>             -- command-specific help\n"
+" roundup-admin help all                   -- all available help\n"
+msgstr ""
+
+#: ../roundup/admin.py:138
+msgid "Commands:"
+msgstr ""
+
+#: ../roundup/admin.py:145
+msgid ""
+"Commands may be abbreviated as long as the abbreviation\n"
+"matches only one command, e.g. l == li == lis == list."
+msgstr ""
+
+#: ../roundup/admin.py:175
+msgid ""
+"\n"
+"All commands (except help) require a tracker specifier. This is just\n"
+"the path to the roundup tracker you're working with. A roundup tracker\n"
+"is where roundup keeps the database and configuration file that defines\n"
+"an issue tracker. It may be thought of as the issue tracker's \"home\n"
+"directory\". It may be specified in the environment variable TRACKER_HOME\n"
+"or on the command line as \"-i tracker\".\n"
+"\n"
+"A designator is a classname and a nodeid concatenated, eg. bug1, user10, ...\n"
+"\n"
+"Property values are represented as strings in command arguments and in the\n"
+"printed results:\n"
+" . Strings are, well, strings.\n"
+" . Date values are printed in the full date format in the local time zone,\n"
+"   and accepted in the full format or any of the partial formats explained\n"
+"   below.\n"
+" . Link values are printed as node designators. When given as an argument,\n"
+"   node designators and key strings are both accepted.\n"
+" . Multilink values are printed as lists of node designators joined\n"
+"   by commas.  When given as an argument, node designators and key\n"
+"   strings are both accepted; an empty string, a single node, or a list\n"
+"   of nodes joined by commas is accepted.\n"
+"\n"
+"When property values must contain spaces, just surround the value with\n"
+"quotes, either ' or \". A single space may also be backslash-quoted. If a\n"
+"value must contain a quote character, it must be backslash-quoted or inside\n"
+"quotes. Examples:\n"
+"           hello world      (2 tokens: hello, world)\n"
+"           \"hello world\"    (1 token: hello world)\n"
+"           \"Roch'e\" Compaan (2 tokens: Roch'e Compaan)\n"
+"           Roch\\'e Compaan  (2 tokens: Roch'e Compaan)\n"
+"           address=\"1 2 3\"  (1 token: address=1 2 3)\n"
+"           \\\\               (1 token: \\)\n"
+"           \\n\\r\\t           (1 token: a newline, carriage-return and tab)\n"
+"\n"
+"When multiple nodes are specified to the roundup get or roundup set\n"
+"commands, the specified properties are retrieved or set on all the listed\n"
+"nodes.\n"
+"\n"
+"When multiple results are returned by the roundup get or roundup find\n"
+"commands, they are printed one per line (default) or joined by commas (with\n"
+"the -c) option.\n"
+"\n"
+"Where the command changes data, a login name/password is required. The\n"
+"login may be specified as either \"name\" or \"name:password\".\n"
+" . ROUNDUP_LOGIN environment variable\n"
+" . the -u command-line option\n"
+"If either the name or password is not supplied, they are obtained from the\n"
+"command-line.\n"
+"\n"
+"Date format examples:\n"
+"  \"2000-04-17.03:45\" means <Date 2000-04-17.08:45:00>\n"
+"  \"2000-04-17\" means <Date 2000-04-17.00:00:00>\n"
+"  \"01-25\" means <Date yyyy-01-25.00:00:00>\n"
+"  \"08-13.22:13\" means <Date yyyy-08-14.03:13:00>\n"
+"  \"11-07.09:32:43\" means <Date yyyy-11-07.14:32:43>\n"
+"  \"14:25\" means <Date yyyy-mm-dd.19:25:00>\n"
+"  \"8:47:11\" means <Date yyyy-mm-dd.13:47:11>\n"
+"  \".\" means \"right now\"\n"
+"\n"
+"Command help:\n"
+msgstr ""
+
+#: ../roundup/admin.py:238
+#, python-format
+msgid "%s:"
+msgstr ""
+
+#: ../roundup/admin.py:243
+msgid ""
+"Usage: help topic\n"
+"        Give help about topic.\n"
+"\n"
+"        commands  -- list commands\n"
+"        <command> -- help specific to a command\n"
+"        initopts  -- init command options\n"
+"        all       -- all available help\n"
+"        "
+msgstr ""
+
+#: ../roundup/admin.py:266
+#, python-format
+msgid "Sorry, no help for \"%(topic)s\""
+msgstr ""
+
+# ../roundup/admin.py:338 :394
+#: ../roundup/admin.py:338 ../roundup/admin.py:394
+msgid "Templates:"
+msgstr ""
+
+# ../roundup/admin.py:341 :405
+#: ../roundup/admin.py:341 ../roundup/admin.py:405
+msgid "Back ends:"
+msgstr ""
+
+#: ../roundup/admin.py:344
+msgid ""
+"Usage: install [template [backend [admin password [key=val[,key=val]]]]]\n"
+"        Install a new Roundup tracker.\n"
+"\n"
+"        The command will prompt for the tracker home directory\n"
+"        (if not supplied through TRACKER_HOME or the -i option).\n"
+"        The template, backend and admin password may be specified\n"
+"        on the command-line as arguments, in that order.\n"
+"\n"
+"        The last command line argument allows to pass initial values\n"
+"        for config options.  For example, passing\n"
+"        \"web_http_auth=no,rdbms_user=dinsdale\" will override defaults\n"
+"        for options http_auth in section [web] and user in section [rdbms].\n"
+"        Please be careful to not use spaces in this argument! (Enclose\n"
+"        whole argument in quotes if you need spaces in option value).\n"
+"\n"
+"        The initialise command must be called after this command in order\n"
+"        to initialise the tracker's database. You may edit the tracker's\n"
+"        initial database contents before running that command by editing\n"
+"        the tracker's dbinit.py module init() function.\n"
+"\n"
+"        See also initopts help.\n"
+"        "
+msgstr ""
+
+# ../roundup/admin.py:367 :464 :525 :604 :654 :712 :733 :761 :832 :899 :970
+# :1018 :1040 :1067 :1134 :1204
+#: ../roundup/admin.py:367 ../roundup/admin.py:464 ../roundup/admin.py:525
+#: ../roundup/admin.py:604 ../roundup/admin.py:654 ../roundup/admin.py:712
+#: ../roundup/admin.py:733 ../roundup/admin.py:761 ../roundup/admin.py:832
+#: ../roundup/admin.py:899 ../roundup/admin.py:970 ../roundup/admin.py:1018
+#: ../roundup/admin.py:1040 ../roundup/admin.py:1067 ../roundup/admin.py:1134
+#: ../roundup/admin.py:1204
+msgid "Not enough arguments supplied"
+msgstr ""
+
+#: ../roundup/admin.py:373
+#, python-format
+msgid "Instance home parent directory \"%(parent)s\" does not exist"
+msgstr ""
+
+#: ../roundup/admin.py:381
+#, python-format
+msgid ""
+"WARNING: There appears to be a tracker in \"%(tracker_home)s\"!\n"
+"If you re-install it, you will lose all the data!\n"
+"Erase it? Y/N: "
+msgstr ""
+
+#: ../roundup/admin.py:396
+msgid "Select template [classic]: "
+msgstr ""
+
+#: ../roundup/admin.py:407
+msgid "Select backend [anydbm]: "
+msgstr ""
+
+#: ../roundup/admin.py:417
+#, python-format
+msgid "Error in configuration settings: \"%s\""
+msgstr ""
+
+#: ../roundup/admin.py:426
+#, python-format
+msgid ""
+"\n"
+"---------------------------------------------------------------------------\n"
+" You should now edit the tracker configuration file:\n"
+"   %(config_file)s"
+msgstr ""
+
+#: ../roundup/admin.py:436
+msgid " ... at a minimum, you must set following options:"
+msgstr ""
+
+#: ../roundup/admin.py:441
+#, python-format
+msgid ""
+"\n"
+" If you wish to modify the database schema,\n"
+" you should also edit the schema file:\n"
+"   %(database_config_file)s\n"
+" You may also change the database initialisation file:\n"
+"   %(database_init_file)s\n"
+" ... see the documentation on customizing for more information.\n"
+"\n"
+" You MUST run the \"roundup-admin initialise\" command once you've performed\n"
+" the above steps.\n"
+"---------------------------------------------------------------------------\n"
+msgstr ""
+
+#: ../roundup/admin.py:459
+msgid ""
+"Usage: genconfig <filename>\n"
+"        Generate a new tracker config file (ini style) with default values\n"
+"        in <filename>.\n"
+"        "
+msgstr ""
+
+#. password
+#: ../roundup/admin.py:469
+msgid ""
+"Usage: initialise [adminpw]\n"
+"        Initialise a new Roundup tracker.\n"
+"\n"
+"        The administrator details will be set at this step.\n"
+"\n"
+"        Execute the tracker's initialisation function dbinit.init()\n"
+"        "
+msgstr ""
+
+#: ../roundup/admin.py:483
+msgid "Admin Password: "
+msgstr ""
+
+#: ../roundup/admin.py:484
+msgid "       Confirm: "
+msgstr ""
+
+#: ../roundup/admin.py:488
+msgid "Instance home does not exist"
+msgstr ""
+
+#: ../roundup/admin.py:492
+msgid "Instance has not been installed"
+msgstr ""
+
+#: ../roundup/admin.py:497
+msgid ""
+"WARNING: The database is already initialised!\n"
+"If you re-initialise it, you will lose all the data!\n"
+"Erase it? Y/N: "
+msgstr ""
+
+#: ../roundup/admin.py:518
+msgid ""
+"Usage: get property designator[,designator]*\n"
+"        Get the given property of one or more designator(s).\n"
+"\n"
+"        Retrieves the property value of the nodes specified\n"
+"        by the designators.\n"
+"        "
+msgstr ""
+
+# ../roundup/admin.py:558 :573
+#: ../roundup/admin.py:558 ../roundup/admin.py:573
+#, python-format
+msgid "property %s is not of type Multilink or Link so -d flag does not apply."
+msgstr ""
+
+# ../roundup/admin.py:581 :981 :1030 :1052
+#: ../roundup/admin.py:581 ../roundup/admin.py:981 ../roundup/admin.py:1030
+#: ../roundup/admin.py:1052
+#, python-format
+msgid "no such %(classname)s node \"%(nodeid)s\""
+msgstr ""
+
+#: ../roundup/admin.py:583
+#, python-format
+msgid "no such %(classname)s property \"%(propname)s\""
+msgstr ""
+
+#: ../roundup/admin.py:592
+msgid ""
+"Usage: set items property=value property=value ...\n"
+"        Set the given properties of one or more items(s).\n"
+"\n"
+"        The items are specified as a class or as a comma-separated\n"
+"        list of item designators (ie \"designator[,designator,...]\").\n"
+"\n"
+"        This command sets the properties to the values for all designators\n"
+"        given. If the value is missing (ie. \"property=\") then the property\n"
+"        is un-set. If the property is a multilink, you specify the linked\n"
+"        ids for the multilink as comma-separated numbers (ie \"1,2,3\").\n"
+"        "
+msgstr ""
+
+#: ../roundup/admin.py:646
+msgid ""
+"Usage: find classname propname=value ...\n"
+"        Find the nodes of the given class with a given link property value.\n"
+"\n"
+"        Find the nodes of the given class with a given link property value.\n"
+"        The value may be either the nodeid of the linked node, or its key\n"
+"        value.\n"
+"        "
+msgstr ""
+
+# ../roundup/admin.py:699 :852 :864 :918
+#: ../roundup/admin.py:699 ../roundup/admin.py:852 ../roundup/admin.py:864
+#: ../roundup/admin.py:918
+#, python-format
+msgid "%(classname)s has no property \"%(propname)s\""
+msgstr ""
+
+#: ../roundup/admin.py:706
+msgid ""
+"Usage: specification classname\n"
+"        Show the properties for a classname.\n"
+"\n"
+"        This lists the properties for a given class.\n"
+"        "
+msgstr ""
+
+#: ../roundup/admin.py:721
+#, python-format
+msgid "%(key)s: %(value)s (key property)"
+msgstr ""
+
+#: ../roundup/admin.py:723
+#, python-format
+msgid "%(key)s: %(value)s"
+msgstr ""
+
+#: ../roundup/admin.py:726
+msgid ""
+"Usage: display designator[,designator]*\n"
+"        Show the property values for the given node(s).\n"
+"\n"
+"        This lists the properties and their associated values for the given\n"
+"        node.\n"
+"        "
+msgstr ""
+
+#: ../roundup/admin.py:750
+#, python-format
+msgid "%(key)s: %(value)r"
+msgstr ""
+
+#: ../roundup/admin.py:753
+msgid ""
+"Usage: create classname property=value ...\n"
+"        Create a new entry of a given class.\n"
+"\n"
+"        This creates a new entry of the given class using the property\n"
+"        name=value arguments provided on the command line after the \"create"
+"\"\n"
+"        command.\n"
+"        "
+msgstr ""
+
+#: ../roundup/admin.py:780
+#, python-format
+msgid "%(propname)s (Password): "
+msgstr ""
+
+#: ../roundup/admin.py:782
+#, python-format
+msgid "   %(propname)s (Again): "
+msgstr ""
+
+#: ../roundup/admin.py:784
+msgid "Sorry, try again..."
+msgstr ""
+
+#: ../roundup/admin.py:788
+#, python-format
+msgid "%(propname)s (%(proptype)s): "
+msgstr ""
+
+#: ../roundup/admin.py:806
+#, python-format
+msgid "you must provide the \"%(propname)s\" property."
+msgstr ""
+
+#: ../roundup/admin.py:817
+msgid ""
+"Usage: list classname [property]\n"
+"        List the instances of a class.\n"
+"\n"
+"        Lists all instances of the given class. If the property is not\n"
+"        specified, the  \"label\" property is used. The label property is\n"
+"        tried in order: the key, \"name\", \"title\" and then the first\n"
+"        property, alphabetically.\n"
+"\n"
+"        With -c, -S or -s print a list of item id's if no property\n"
+"        specified.  If property specified, print list of that property\n"
+"        for every class instance.\n"
+"        "
+msgstr ""
+
+#: ../roundup/admin.py:830
+msgid "Too many arguments supplied"
+msgstr ""
+
+#: ../roundup/admin.py:866
+#, python-format
+msgid "%(nodeid)4s: %(value)s"
+msgstr ""
+
+#: ../roundup/admin.py:870
+msgid ""
+"Usage: table classname [property[,property]*]\n"
+"        List the instances of a class in tabular form.\n"
+"\n"
+"        Lists all instances of the given class. If the properties are not\n"
+"        specified, all properties are displayed. By default, the column\n"
+"        widths are the width of the largest value. The width may be\n"
+"        explicitly defined by defining the property as \"name:width\".\n"
+"        For example::\n"
+"\n"
+"          roundup> table priority id,name:10\n"
+"          Id Name\n"
+"          1  fatal-bug\n"
+"          2  bug\n"
+"          3  usability\n"
+"          4  feature\n"
+"\n"
+"        Also to make the width of the column the width of the label,\n"
+"        leave a trailing : without a width on the property. For example::\n"
+"\n"
+"          roundup> table priority id,name:\n"
+"          Id Name\n"
+"          1  fata\n"
+"          2  bug\n"
+"          3  usab\n"
+"          4  feat\n"
+"\n"
+"        will result in a the 4 character wide \"Name\" column.\n"
+"        "
+msgstr ""
+
+#: ../roundup/admin.py:914
+#, python-format
+msgid "\"%(spec)s\" not name:width"
+msgstr ""
+
+#: ../roundup/admin.py:964
+msgid ""
+"Usage: history designator\n"
+"        Show the history entries of a designator.\n"
+"\n"
+"        Lists the journal entries for the node identified by the designator.\n"
+"        "
+msgstr ""
+
+#: ../roundup/admin.py:985
+msgid ""
+"Usage: commit\n"
+"        Commit changes made to the database during an interactive session.\n"
+"\n"
+"        The changes made during an interactive session are not\n"
+"        automatically written to the database - they must be committed\n"
+"        using this command.\n"
+"\n"
+"        One-off commands on the command-line are automatically committed if\n"
+"        they are successful.\n"
+"        "
+msgstr ""
+
+#: ../roundup/admin.py:999
+msgid ""
+"Usage: rollback\n"
+"        Undo all changes that are pending commit to the database.\n"
+"\n"
+"        The changes made during an interactive session are not\n"
+"        automatically written to the database - they must be committed\n"
+"        manually. This command undoes all those changes, so a commit\n"
+"        immediately after would make no changes to the database.\n"
+"        "
+msgstr ""
+
+#: ../roundup/admin.py:1011
+msgid ""
+"Usage: retire designator[,designator]*\n"
+"        Retire the node specified by designator.\n"
+"\n"
+"        This action indicates that a particular node is not to be retrieved\n"
+"        by the list or find commands, and its key value may be re-used.\n"
+"        "
+msgstr ""
+
+#: ../roundup/admin.py:1034
+msgid ""
+"Usage: restore designator[,designator]*\n"
+"        Restore the retired node specified by designator.\n"
+"\n"
+"        The given nodes will become available for users again.\n"
+"        "
+msgstr ""
+
+#. grab the directory to export to
+#: ../roundup/admin.py:1056
+msgid ""
+"Usage: export [class[,class]] export_dir\n"
+"        Export the database to colon-separated-value files.\n"
+"\n"
+"        Optionally limit the export to just the names classes.\n"
+"\n"
+"        This action exports the current data from the database into\n"
+"        colon-separated-value files that are placed in the nominated\n"
+"        destination directory.\n"
+"        "
+msgstr ""
+
+#: ../roundup/admin.py:1114
+msgid ""
+"Usage: import import_dir\n"
+"        Import a database from the directory containing CSV files,\n"
+"        two per class to import.\n"
+"\n"
+"        The files used in the import are:\n"
+"\n"
+"        <class>.csv\n"
+"          This must define the same properties as the class (including\n"
+"          having a \"header\" line with those property names.)\n"
+"        <class>-journals.csv\n"
+"          This defines the journals for the items being imported.\n"
+"\n"
+"        The imported nodes will have the same nodeid as defined in the\n"
+"        import file, thus replacing any existing content.\n"
+"\n"
+"        The new nodes are added to the existing database - if you want to\n"
+"        create a new database using the imported data, then create a new\n"
+"        database (or, tediously, retire all the old data.)\n"
+"        "
+msgstr ""
+
+#: ../roundup/admin.py:1186
+msgid ""
+"Usage: pack period | date\n"
+"\n"
+"        Remove journal entries older than a period of time specified or\n"
+"        before a certain date.\n"
+"\n"
+"        A period is specified using the suffixes \"y\", \"m\", and \"d\". "
+"The\n"
+"        suffix \"w\" (for \"week\") means 7 days.\n"
+"\n"
+"              \"3y\" means three years\n"
+"              \"2y 1m\" means two years and one month\n"
+"              \"1m 25d\" means one month and 25 days\n"
+"              \"2w 3d\" means two weeks and three days\n"
+"\n"
+"        Date format is \"YYYY-MM-DD\" eg:\n"
+"            2001-01-01\n"
+"\n"
+"        "
+msgstr ""
+
+#: ../roundup/admin.py:1214
+msgid "Invalid format"
+msgstr ""
+
+#: ../roundup/admin.py:1224
+msgid ""
+"Usage: reindex [classname|designator]*\n"
+"        Re-generate a tracker's search indexes.\n"
+"\n"
+"        This will re-generate the search indexes for a tracker.\n"
+"        This will typically happen automatically.\n"
+"        "
+msgstr ""
+
+#: ../roundup/admin.py:1238
+#, python-format
+msgid "no such item \"%(designator)s\""
+msgstr ""
+
+#: ../roundup/admin.py:1248
+msgid ""
+"Usage: security [Role name]\n"
+"        Display the Permissions available to one or all Roles.\n"
+"        "
+msgstr ""
+
+#: ../roundup/admin.py:1256
+#, python-format
+msgid "No such Role \"%(role)s\""
+msgstr ""
+
+#: ../roundup/admin.py:1262
+#, python-format
+msgid "New Web users get the Roles \"%(role)s\""
+msgstr ""
+
+#: ../roundup/admin.py:1264
+#, python-format
+msgid "New Web users get the Role \"%(role)s\""
+msgstr ""
+
+#: ../roundup/admin.py:1267
+#, python-format
+msgid "New Email users get the Roles \"%(role)s\""
+msgstr ""
+
+#: ../roundup/admin.py:1269
+#, python-format
+msgid "New Email users get the Role \"%(role)s\""
+msgstr ""
+
+#: ../roundup/admin.py:1272
+#, python-format
+msgid "Role \"%(name)s\":"
+msgstr ""
+
+#: ../roundup/admin.py:1277
+#, python-format
+msgid " %(description)s (%(name)s for \"%(klass)s\": %(properties)s only)"
+msgstr ""
+
+#: ../roundup/admin.py:1280
+#, python-format
+msgid " %(description)s (%(name)s for \"%(klass)s\" only)"
+msgstr ""
+
+#: ../roundup/admin.py:1283
+#, python-format
+msgid " %(description)s (%(name)s)"
+msgstr ""
+
+#: ../roundup/admin.py:1312
+#, python-format
+msgid "Unknown command \"%(command)s\" (\"help commands\" for a list)"
+msgstr ""
+
+#: ../roundup/admin.py:1318
+#, python-format
+msgid "Multiple commands match \"%(command)s\": %(list)s"
+msgstr ""
+
+#: ../roundup/admin.py:1325
+msgid "Enter tracker home: "
+msgstr ""
+
+# ../roundup/admin.py:1332 :1338 :1358
+#: ../roundup/admin.py:1332 ../roundup/admin.py:1338 ../roundup/admin.py:1358
+#, python-format
+msgid "Error: %(message)s"
+msgstr ""
+
+#: ../roundup/admin.py:1346
+#, python-format
+msgid "Error: Couldn't open tracker: %(message)s"
+msgstr ""
+
+#: ../roundup/admin.py:1371
+#, python-format
+msgid ""
+"Roundup %s ready for input.\n"
+"Type \"help\" for help."
+msgstr ""
+
+#: ../roundup/admin.py:1376
+msgid "Note: command history and editing not available"
+msgstr ""
+
+#: ../roundup/admin.py:1380
+msgid "roundup> "
+msgstr ""
+
+#: ../roundup/admin.py:1382
+msgid "exit..."
+msgstr ""
+
+#: ../roundup/admin.py:1392
+msgid "There are unsaved changes. Commit them (y/N)? "
+msgstr ""
+
+#: ../roundup/backends/back_anydbm.py:1997
+#, python-format
+msgid "WARNING: invalid date tuple %r"
+msgstr ""
+
+#: ../roundup/backends/rdbms_common.py:1434
+msgid "create"
+msgstr ""
+
+#: ../roundup/backends/rdbms_common.py:1600
+msgid "unlink"
+msgstr ""
+
+#: ../roundup/backends/rdbms_common.py:1604
+msgid "link"
+msgstr ""
+
+#: ../roundup/backends/rdbms_common.py:1724
+msgid "set"
+msgstr ""
+
+#: ../roundup/backends/rdbms_common.py:1748
+msgid "retired"
+msgstr ""
+
+#: ../roundup/backends/rdbms_common.py:1778
+msgid "restored"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:58
+#, python-format
+msgid "You do not have permission to %(action)s the %(classname)s class."
+msgstr ""
+
+#: ../roundup/cgi/actions.py:89
+msgid "No type specified"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:91
+msgid "No ID entered"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:97
+#, python-format
+msgid "\"%(input)s\" is not an ID (%(classname)s ID required)"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:117
+msgid "You may not retire the admin or anonymous user"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:124
+#, python-format
+msgid "%(classname)s %(itemid)s has been retired"
+msgstr ""
+
+# ../roundup/cgi/actions.py:174 :202
+#: ../roundup/cgi/actions.py:174 ../roundup/cgi/actions.py:202
+msgid "You do not have permission to edit queries"
+msgstr ""
+
+# ../roundup/cgi/actions.py:180 :209
+#: ../roundup/cgi/actions.py:180 ../roundup/cgi/actions.py:209
+msgid "You do not have permission to store queries"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:297
+#, python-format
+msgid "Not enough values on line %(line)s"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:344
+msgid "Items edited OK"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:404
+#, python-format
+msgid "%(class)s %(id)s %(properties)s edited ok"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:407
+#, python-format
+msgid "%(class)s %(id)s - nothing changed"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:419
+#, python-format
+msgid "%(class)s %(id)s created"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:451
+#, python-format
+msgid "You do not have permission to edit %(class)s"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:463
+#, python-format
+msgid "You do not have permission to create %(class)s"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:487
+msgid "You do not have permission to edit user roles"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:537
+#, python-format
+msgid ""
+"Edit Error: someone else has edited this %s (%s). View <a target=\"new\" href="
+"\"%s%s\">their changes</a> in a new window."
+msgstr ""
+
+#: ../roundup/cgi/actions.py:565
+#, python-format
+msgid "Edit Error: %s"
+msgstr ""
+
+# ../roundup/cgi/actions.py:596 :607 :778 :797
+#: ../roundup/cgi/actions.py:596 ../roundup/cgi/actions.py:607
+#: ../roundup/cgi/actions.py:778 ../roundup/cgi/actions.py:797
+#, python-format
+msgid "Error: %s"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:633
+msgid ""
+"Invalid One Time Key!\n"
+"(a Mozilla bug may cause this message to show up erroneously, please check "
+"your email)"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:675
+#, python-format
+msgid "Password reset and email sent to %s"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:684
+msgid "Unknown username"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:692
+msgid "Unknown email address"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:697
+msgid "You need to specify a username or address"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:722
+#, python-format
+msgid "Email sent to %s"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:741
+msgid "You are now registered, welcome!"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:786
+msgid "It is not permitted to supply roles at registration."
+msgstr ""
+
+#: ../roundup/cgi/actions.py:878
+msgid "You are logged out"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:895
+msgid "Username required"
+msgstr ""
+
+# ../roundup/cgi/actions.py:930 :934
+#: ../roundup/cgi/actions.py:930 ../roundup/cgi/actions.py:934
+msgid "Invalid login"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:940
+msgid "You do not have permission to login"
+msgstr ""
+
+#: ../roundup/cgi/cgitb.py:49
+#, python-format
+msgid ""
+"<h1>Templating Error</h1>\n"
+"<p><b>%(exc_type)s</b>: %(exc_value)s</p>\n"
+"<p class=\"help\">Debugging information follows</p>"
+msgstr ""
+
+#: ../roundup/cgi/cgitb.py:64
+#, python-format
+msgid "<li>\"%(name)s\" (%(info)s)</li>"
+msgstr ""
+
+#: ../roundup/cgi/cgitb.py:67
+#, python-format
+msgid "<li>Looking for \"%(name)s\", current path:<ol>%(path)s</ol></li>"
+msgstr ""
+
+#: ../roundup/cgi/cgitb.py:71
+#, python-format
+msgid "<li>In %s</li>"
+msgstr ""
+
+#: ../roundup/cgi/cgitb.py:76
+#, python-format
+msgid "A problem occurred in your template \"%s\"."
+msgstr ""
+
+#: ../roundup/cgi/cgitb.py:84
+#, python-format
+msgid ""
+"\n"
+"<li>While evaluating the %(info)r expression on line %(line)d\n"
+"<table class=\"otherinfo\" style=\"font-size: 90%%\">\n"
+" <tr><th colspan=\"2\" class=\"header\">Current variables:</th></tr>\n"
+" %(globals)s\n"
+" %(locals)s\n"
+"</table></li>\n"
+msgstr ""
+
+#: ../roundup/cgi/cgitb.py:103
+msgid "Full traceback:"
+msgstr ""
+
+#: ../roundup/cgi/cgitb.py:116
+#, python-format
+msgid "<font size=+1><strong>%(exc_type)s</strong>: %(exc_value)s</font>"
+msgstr ""
+
+#: ../roundup/cgi/cgitb.py:120
+msgid ""
+"<p>A problem occurred while running a Python script. Here is the sequence of "
+"function calls leading up to the error, with the most recent (innermost) call "
+"first. The exception attributes are:"
+msgstr ""
+
+#: ../roundup/cgi/cgitb.py:129
+msgid "&lt;file is None - probably inside <tt>eval</tt> or <tt>exec</tt>&gt;"
+msgstr ""
+
+#: ../roundup/cgi/cgitb.py:138
+#, python-format
+msgid "in <strong>%s</strong>"
+msgstr ""
+
+# ../roundup/cgi/cgitb.py:172 :178
+#: ../roundup/cgi/cgitb.py:172 ../roundup/cgi/cgitb.py:178
+msgid "<em>undefined</em>"
+msgstr ""
+
+#: ../roundup/cgi/client.py:49
+msgid ""
+"<html><head><title>An error has occurred</title></head>\n"
+"<body><h1>An error has occurred</h1>\n"
+"<p>A problem was encountered processing your request.\n"
+"The tracker maintainers have been notified of the problem.</p>\n"
+"</body></html>"
+msgstr ""
+
+#: ../roundup/cgi/client.py:308
+msgid "Form Error: "
+msgstr ""
+
+#: ../roundup/cgi/client.py:363
+#, python-format
+msgid "Unrecognized charset: %r"
+msgstr ""
+
+#: ../roundup/cgi/client.py:490
+msgid "Anonymous users are not allowed to use the web interface"
+msgstr ""
+
+#: ../roundup/cgi/client.py:645
+msgid "You are not allowed to view this file."
+msgstr ""
+
+#: ../roundup/cgi/client.py:737
+#, python-format
+msgid "%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n"
+msgstr ""
+
+#: ../roundup/cgi/client.py:741
+#, python-format
+msgid ""
+"%(starttag)sCache hits: %(cache_hits)d, misses %(cache_misses)d. Loading "
+"items: %(get_items)f secs. Filtering: %(filtering)f secs.%(endtag)s\n"
+msgstr ""
+
+#: ../roundup/cgi/form_parser.py:283
+#, python-format
+msgid "link \"%(key)s\" value \"%(value)s\" not a designator"
+msgstr ""
+
+#: ../roundup/cgi/form_parser.py:290
+#, python-format
+msgid "%(class)s %(property)s is not a link or multilink property"
+msgstr ""
+
+#: ../roundup/cgi/form_parser.py:312
+#, python-format
+msgid ""
+"You have submitted a %(action)s action for the property \"%(property)s\" "
+"which doesn't exist"
+msgstr ""
+
+# ../roundup/cgi/form_parser.py:331 :357
+#: ../roundup/cgi/form_parser.py:331 ../roundup/cgi/form_parser.py:357
+#, python-format
+msgid "You have submitted more than one value for the %s property"
+msgstr ""
+
+# ../roundup/cgi/form_parser.py:354 :360
+#: ../roundup/cgi/form_parser.py:354 ../roundup/cgi/form_parser.py:360
+msgid "Password and confirmation text do not match"
+msgstr ""
+
+#: ../roundup/cgi/form_parser.py:395
+#, python-format
+msgid "property \"%(propname)s\": \"%(value)s\" not currently in list"
+msgstr ""
+
+#: ../roundup/cgi/form_parser.py:512
+#, python-format
+msgid "Required %(class)s property %(property)s not supplied"
+msgid_plural "Required %(class)s properties %(property)s not supplied"
+msgstr[0] ""
+msgstr[1] ""
+
+#: ../roundup/cgi/form_parser.py:535
+msgid "File is empty"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:72
+#, python-format
+msgid "You are not allowed to %(action)s items of class %(class)s"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:627
+msgid "(list)"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:696
+msgid "Submit New Entry"
+msgstr ""
+
+# ../roundup/cgi/templating.py:710 :829 :1236 :1257 :1304 :1327 :1361 :1400
+# :1453 :1470 :1549 :1569 :1587 :1619 :1629 :1683 :1875
+#: ../roundup/cgi/templating.py:710 ../roundup/cgi/templating.py:829
+#: ../roundup/cgi/templating.py:1236 ../roundup/cgi/templating.py:1257
+#: ../roundup/cgi/templating.py:1304 ../roundup/cgi/templating.py:1327
+#: ../roundup/cgi/templating.py:1361 ../roundup/cgi/templating.py:1400
+#: ../roundup/cgi/templating.py:1453 ../roundup/cgi/templating.py:1470
+#: ../roundup/cgi/templating.py:1549 ../roundup/cgi/templating.py:1569
+#: ../roundup/cgi/templating.py:1587 ../roundup/cgi/templating.py:1619
+#: ../roundup/cgi/templating.py:1629 ../roundup/cgi/templating.py:1683
+#: ../roundup/cgi/templating.py:1875
+msgid "[hidden]"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:711
+msgid "New node - no history"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:811
+msgid "Submit Changes"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:893
+msgid "<em>The indicated property no longer exists</em>"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:894
+#, python-format
+msgid "<em>%s: %s</em>\n"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:907
+#, python-format
+msgid "The linked class %(classname)s no longer exists"
+msgstr ""
+
+# ../roundup/cgi/templating.py:940 :964
+#: ../roundup/cgi/templating.py:940 ../roundup/cgi/templating.py:964
+msgid "<strike>The linked node no longer exists</strike>"
+msgstr ""
+
+# ../roundup/cgi/templating.py:1006 :1404 :1425 :1431
+#: ../roundup/cgi/templating.py:1006 ../roundup/cgi/templating.py:1404
+#: ../roundup/cgi/templating.py:1425 ../roundup/cgi/templating.py:1431
+msgid "No"
+msgstr ""
+
+# ../roundup/cgi/templating.py:1006 :1404 :1423 :1428
+#: ../roundup/cgi/templating.py:1006 ../roundup/cgi/templating.py:1404
+#: ../roundup/cgi/templating.py:1423 ../roundup/cgi/templating.py:1428
+msgid "Yes"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1017
+#, python-format
+msgid "%s: (no value)"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1029
+msgid ""
+"<strong><em>This event is not handled by the history display!</em></strong>"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1041
+msgid "<tr><td colspan=4><strong>Note:</strong></td></tr>"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1050
+msgid "History"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1052
+msgid "<th>Date</th>"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1053
+msgid "<th>User</th>"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1054
+msgid "<th>Action</th>"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1055
+msgid "<th>Args</th>"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1097
+#, python-format
+msgid "Copy of %(class)s %(id)s"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1331
+msgid "*encrypted*"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1514
+msgid ""
+"default value for DateHTMLProperty must be either DateHTMLProperty or string "
+"date representation."
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1674
+#, python-format
+msgid "Attempt to look up %(attr)s on a missing value"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1750
+#, python-format
+msgid "<option %svalue=\"-1\">- no selection -</option>"
+msgstr ""
+
+#: ../roundup/date.py:186
+msgid ""
+"Not a date spec: \"yyyy-mm-dd\", \"mm-dd\", \"HH:MM\", \"HH:MM:SS\" or \"yyyy-"
+"mm-dd.HH:MM:SS.SSS\""
+msgstr ""
+
+#: ../roundup/date.py:240
+#, python-format
+msgid ""
+"%r not a date / time spec \"yyyy-mm-dd\", \"mm-dd\", \"HH:MM\", \"HH:MM:SS\" "
+"or \"yyyy-mm-dd.HH:MM:SS.SSS\""
+msgstr ""
+
+#: ../roundup/date.py:538
+msgid ""
+"Not an interval spec: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS] [date spec]"
+msgstr ""
+
+#: ../roundup/date.py:557
+msgid "Not an interval spec: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS]"
+msgstr ""
+
+#: ../roundup/date.py:694
+#, python-format
+msgid "%(number)s year"
+msgid_plural "%(number)s years"
+msgstr[0] ""
+msgstr[1] ""
+
+#: ../roundup/date.py:698
+#, python-format
+msgid "%(number)s month"
+msgid_plural "%(number)s months"
+msgstr[0] ""
+msgstr[1] ""
+
+#: ../roundup/date.py:702
+#, python-format
+msgid "%(number)s week"
+msgid_plural "%(number)s weeks"
+msgstr[0] ""
+msgstr[1] ""
+
+#: ../roundup/date.py:706
+#, python-format
+msgid "%(number)s day"
+msgid_plural "%(number)s days"
+msgstr[0] ""
+msgstr[1] ""
+
+#: ../roundup/date.py:710
+msgid "tomorrow"
+msgstr ""
+
+#: ../roundup/date.py:712
+msgid "yesterday"
+msgstr ""
+
+#: ../roundup/date.py:715
+#, python-format
+msgid "%(number)s hour"
+msgid_plural "%(number)s hours"
+msgstr[0] ""
+msgstr[1] ""
+
+#: ../roundup/date.py:719
+msgid "an hour"
+msgstr ""
+
+#: ../roundup/date.py:721
+msgid "1 1/2 hours"
+msgstr ""
+
+#: ../roundup/date.py:723
+#, python-format
+msgid "1 %(number)s/4 hours"
+msgid_plural "1 %(number)s/4 hours"
+msgstr[0] ""
+msgstr[1] ""
+
+#: ../roundup/date.py:727
+msgid "in a moment"
+msgstr ""
+
+#: ../roundup/date.py:729
+msgid "just now"
+msgstr ""
+
+#: ../roundup/date.py:732
+msgid "1 minute"
+msgstr ""
+
+#: ../roundup/date.py:735
+#, python-format
+msgid "%(number)s minute"
+msgid_plural "%(number)s minutes"
+msgstr[0] ""
+msgstr[1] ""
+
+#: ../roundup/date.py:738
+msgid "1/2 an hour"
+msgstr ""
+
+#: ../roundup/date.py:740
+#, python-format
+msgid "%(number)s/4 hour"
+msgid_plural "%(number)s/4 hours"
+msgstr[0] ""
+msgstr[1] ""
+
+#: ../roundup/date.py:744
+#, python-format
+msgid "%s ago"
+msgstr ""
+
+#: ../roundup/date.py:746
+#, python-format
+msgid "in %s"
+msgstr ""
+
+#: ../roundup/init.py:134
+#, python-format
+msgid ""
+"WARNING: directory '%s'\n"
+"\tcontains old-style template - ignored"
+msgstr ""
+
+#: ../roundup/roundupdb.py:141
+msgid "files"
+msgstr ""
+
+#: ../roundup/roundupdb.py:141
+msgid "messages"
+msgstr ""
+
+#: ../roundup/roundupdb.py:141
+msgid "nosy"
+msgstr ""
+
+#: ../roundup/roundupdb.py:141
+msgid "superseder"
+msgstr ""
+
+#: ../roundup/roundupdb.py:141
+msgid "title"
+msgstr ""
+
+#: ../roundup/roundupdb.py:142
+msgid "assignedto"
+msgstr ""
+
+#: ../roundup/roundupdb.py:142
+msgid "priority"
+msgstr ""
+
+#: ../roundup/roundupdb.py:142
+msgid "status"
+msgstr ""
+
+#: ../roundup/roundupdb.py:142
+msgid "topic"
+msgstr ""
+
+#: ../roundup/roundupdb.py:145
+msgid "activity"
+msgstr ""
+
+#. following properties are common for all hyperdb classes
+#. they are listed here to keep things in one place
+#: ../roundup/roundupdb.py:145
+msgid "actor"
+msgstr ""
+
+#: ../roundup/roundupdb.py:145
+msgid "creation"
+msgstr ""
+
+#: ../roundup/roundupdb.py:145
+msgid "creator"
+msgstr ""
+
+#: ../roundup/scripts/roundup_demo.py:32
+#, python-format
+msgid "Enter directory path to create demo tracker [%s]: "
+msgstr ""
+
+#: ../roundup/scripts/roundup_gettext.py:22
+#, python-format
+msgid "Usage: %(program)s <tracker home>"
+msgstr ""
+
+#: ../roundup/scripts/roundup_gettext.py:37
+#, python-format
+msgid "No tracker templates found in directory %s"
+msgstr ""
+
+#: ../roundup/scripts/roundup_mailgw.py:36
+#, python-format
+msgid ""
+"Usage: %(program)s [-v] [-c] [[-C class] -S field=value]* <instance home> "
+"[method]\n"
+"\n"
+"Options:\n"
+" -v: print version and exit\n"
+" -c: default class of item to create (else the tracker's MAIL_DEFAULT_CLASS)\n"
+" -C / -S: see below\n"
+"\n"
+"The roundup mail gateway may be called in one of four ways:\n"
+" . with an instance home as the only argument,\n"
+" . with both an instance home and a mail spool file,\n"
+" . with both an instance home and a POP/APOP server account, or\n"
+" . with both an instance home and a IMAP/IMAPS server account.\n"
+"\n"
+"It also supports optional -C and -S arguments that allows you to set a\n"
+"fields for a class created by the roundup-mailgw. The default class if\n"
+"not specified is msg, but the other classes: issue, file, user can\n"
+"also be used. The -S or --set options uses the same\n"
+"property=value[;property=value] notation accepted by the command line\n"
+"roundup command or the commands that can be given on the Subject line\n"
+"of an email message.\n"
+"\n"
+"It can let you set the type of the message on a per email address basis.\n"
+"\n"
+"PIPE:\n"
+" In the first case, the mail gateway reads a single message from the\n"
+" standard input and submits the message to the roundup.mailgw module.\n"
+"\n"
+"UNIX mailbox:\n"
+" In the second case, the gateway reads all messages from the mail spool\n"
+" file and submits each in turn to the roundup.mailgw module. The file is\n"
+" emptied once all messages have been successfully handled. The file is\n"
+" specified as:\n"
+"   mailbox /path/to/mailbox\n"
+"\n"
+"POP:\n"
+" In the third case, the gateway reads all messages from the POP server\n"
+" specified and submits each in turn to the roundup.mailgw module. The\n"
+" server is specified as:\n"
+"    pop username:password at server\n"
+" The username and password may be omitted:\n"
+"    pop username at server\n"
+"    pop server\n"
+" are both valid. The username and/or password will be prompted for if\n"
+" not supplied on the command-line.\n"
+"\n"
+"APOP:\n"
+" Same as POP, but using Authenticated POP:\n"
+"    apop username:password at server\n"
+"\n"
+"IMAP:\n"
+" Connect to an IMAP server. This supports the same notation as that of\n"
+" POP mail.\n"
+"    imap username:password at server\n"
+" It also allows you to specify a specific mailbox other than INBOX using\n"
+" this format:\n"
+"    imap username:password at server mailbox\n"
+"\n"
+"IMAPS:\n"
+" Connect to an IMAP server over ssl.\n"
+" This supports the same notation as IMAP.\n"
+"    imaps username:password at server [mailbox]\n"
+"\n"
+msgstr ""
+
+#: ../roundup/scripts/roundup_mailgw.py:147
+msgid "Error: not enough source specification information"
+msgstr ""
+
+#: ../roundup/scripts/roundup_mailgw.py:163
+msgid "Error: pop specification not valid"
+msgstr ""
+
+#: ../roundup/scripts/roundup_mailgw.py:170
+msgid "Error: apop specification not valid"
+msgstr ""
+
+#: ../roundup/scripts/roundup_mailgw.py:184
+msgid ""
+"Error: The source must be either \"mailbox\", \"pop\", \"apop\", \"imap\" or "
+"\"imaps\""
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:157
+msgid ""
+"<html><head><title>Roundup trackers index</title></head>\n"
+"<body><h1>Roundup trackers index</h1><ol>\n"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:287
+#, python-format
+msgid "Error: %s: %s"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:297
+msgid "WARNING: ignoring \"-g\" argument, not root"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:303
+msgid "Can't change groups - no grp module"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:312
+#, python-format
+msgid "Group %(group)s doesn't exist"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:323
+msgid "Can't run as root!"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:326
+msgid "WARNING: ignoring \"-u\" argument, not root"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:331
+msgid "Can't change users - no pwd module"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:340
+#, python-format
+msgid "User %(user)s doesn't exist"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:471
+#, python-format
+msgid "Multiprocess mode \"%s\" is not available, switching to single-process"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:494
+#, python-format
+msgid "Unable to bind to port %s, port already in use."
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:562
+msgid ""
+" -c <Command>  Windows Service options.\n"
+"               If you want to run the server as a Windows Service, you\n"
+"               must use configuration file to specify tracker homes.\n"
+"               Logfile option is required to run Roundup Tracker service.\n"
+"               Typing \"roundup-server -c help\" shows Windows Services\n"
+"               specifics."
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:569
+msgid ""
+" -u <UID>      runs the Roundup web server as this UID\n"
+" -g <GID>      runs the Roundup web server as this GID\n"
+" -d <PIDfile>  run the server in the background and write the server's PID\n"
+"               to the file indicated by PIDfile. The -l option *must* be\n"
+"               specified if -d is used."
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:576
+#, python-format
+msgid ""
+"%(message)sUsage: roundup-server [options] [name=tracker home]*\n"
+"\n"
+"Options:\n"
+" -v            print the Roundup version number and exit\n"
+" -h            print this text and exit\n"
+" -S            create or update configuration file and exit\n"
+" -C <fname>    use configuration file <fname>\n"
+" -n <name>     set the host name of the Roundup web server instance\n"
+" -p <port>     set the port to listen on (default: %(port)s)\n"
+" -l <fname>    log to the file indicated by fname instead of stderr/stdout\n"
+" -N            log client machine names instead of IP addresses (much "
+"slower)\n"
+" -t <mode>     multiprocess mode (default: %(mp_def)s).\n"
+"               Allowed values: %(mp_types)s.\n"
+"%(os_part)s\n"
+"\n"
+"Long options:\n"
+" --version          print the Roundup version number and exit\n"
+" --help             print this text and exit\n"
+" --save-config      create or update configuration file and exit\n"
+" --config <fname>   use configuration file <fname>\n"
+" All settings of the [main] section of the configuration file\n"
+" also may be specified in form --<name>=<value>\n"
+"\n"
+"Examples:\n"
+"\n"
+" roundup-server -S -C /opt/roundup/etc/roundup-server.ini \\\n"
+"    -n localhost -p 8917 -l /var/log/roundup.log \\\n"
+"    support=/var/spool/roundup-trackers/support\n"
+"\n"
+" roundup-server -C /opt/roundup/etc/roundup-server.ini\n"
+"\n"
+" roundup-server support=/var/spool/roundup-trackers/support\n"
+"\n"
+" roundup-server -d /var/run/roundup.pid -l /var/log/roundup.log \\\n"
+"    support=/var/spool/roundup-trackers/support\n"
+"\n"
+"Configuration file format:\n"
+"   Roundup Server configuration file has common .ini file format.\n"
+"   Configuration file created with 'roundup-server -S' contains\n"
+"   detailed explanations for each option.  Please see that file\n"
+"   for option descriptions.\n"
+"\n"
+"How to use \"name=tracker home\":\n"
+"   These arguments set the tracker home(s) to use. The name is how the\n"
+"   tracker is identified in the URL (it's the first part of the URL path).\n"
+"   The tracker home is the directory that was identified when you did\n"
+"   \"roundup-admin init\". You may specify any number of these name=home\n"
+"   pairs on the command-line. Make sure the name part doesn't include\n"
+"   any url-unsafe characters like spaces, as these confuse IE.\n"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:724
+msgid "Instances must be name=home"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:738
+#, python-format
+msgid "Configuration saved to %s"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:756
+msgid "Sorry, you can't run the server as a daemon on this Operating System"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:768
+#, python-format
+msgid "Roundup server started on %(HOST)s:%(PORT)s"
+msgstr ""
+
+#: ../templates/classic/html/_generic.collision.html:4
+#: ../templates/minimal/html/_generic.collision.html:4
+msgid "${class} Edit Collision - ${tracker}"
+msgstr ""
+
+#: ../templates/classic/html/_generic.collision.html:7
+#: ../templates/minimal/html/_generic.collision.html:7
+msgid "${class} Edit Collision"
+msgstr ""
+
+#: ../templates/classic/html/_generic.collision.html:14
+#: ../templates/minimal/html/_generic.collision.html:14
+msgid ""
+"\n"
+"  There has been a collision. Another user updated this node\n"
+"  while you were editing. Please <a href='${context}'>reload</a>\n"
+"  the node and review your edits.\n"
+msgstr ""
+
+#: ../templates/classic/html/_generic.help.html:9
+#: ../templates/minimal/html/_generic.help.html:9
+msgid "${property} help - ${tracker}"
+msgstr ""
+
+#: ../templates/classic/html/_generic.help.html:31
+#: ../templates/minimal/html/_generic.help.html:31
+msgid " Cancel "
+msgstr ""
+
+#: ../templates/classic/html/_generic.help.html:34
+#: ../templates/minimal/html/_generic.help.html:34
+msgid " Apply "
+msgstr ""
+
+#: ../templates/classic/html/_generic.help.html:41
+#: ../templates/classic/html/issue.index.html:73
+#: ../templates/minimal/html/_generic.help.html:41
+msgid "&lt;&lt; previous"
+msgstr ""
+
+#: ../templates/classic/html/_generic.help.html:52
+#: ../templates/classic/html/issue.index.html:81
+#: ../templates/minimal/html/_generic.help.html:52
+msgid "${start}..${end} out of ${total}"
+msgstr ""
+
+#: ../templates/classic/html/_generic.help.html:56
+#: ../templates/classic/html/issue.index.html:84
+#: ../templates/minimal/html/_generic.help.html:56
+msgid "next &gt;&gt;"
+msgstr ""
+
+#: ../templates/classic/html/_generic.index.html:6
+#: ../templates/classic/html/_generic.item.html:4
+#: ../templates/minimal/html/_generic.index.html:6
+#: ../templates/minimal/html/_generic.item.html:4
+msgid "${class} editing - ${tracker}"
+msgstr ""
+
+#: ../templates/classic/html/_generic.index.html:9
+#: ../templates/classic/html/_generic.item.html:7
+#: ../templates/minimal/html/_generic.index.html:9
+#: ../templates/minimal/html/_generic.item.html:7
+msgid "${class} editing"
+msgstr ""
+
+#: ../templates/classic/html/_generic.index.html:14
+#: ../templates/classic/html/_generic.item.html:12
+#: ../templates/classic/html/file.item.html:9
+#: ../templates/classic/html/issue.index.html:16
+#: ../templates/classic/html/issue.item.html:28
+#: ../templates/classic/html/msg.item.html:26
+#: ../templates/classic/html/user.index.html:9
+#: ../templates/classic/html/user.item.html:28
+#: ../templates/minimal/html/_generic.index.html:14
+#: ../templates/minimal/html/_generic.item.html:12
+#: ../templates/minimal/html/user.index.html:9
+#: ../templates/minimal/html/user.item.html:28
+#: ../templates/minimal/html/user.register.html:14
+msgid "You are not allowed to view this page."
+msgstr ""
+
+#: ../templates/classic/html/_generic.index.html:22
+#: ../templates/minimal/html/_generic.index.html:22
+msgid ""
+"<p class=\"form-help\"> You may edit the contents of the ${classname} class "
+"using this form. Commas, newlines and double quotes (\") must be handled "
+"delicately. You may include commas and newlines by enclosing the values in "
+"double-quotes (\"). Double quotes themselves must be quoted by doubling "
+"(\"\"). </p> <p class=\"form-help\"> Multilink properties have their multiple "
+"values colon (\":\") separated (... ,\"one:two:three\", ...) </p> <p class="
+"\"form-help\"> Remove entries by deleting their line. Add new entries by "
+"appending them to the table - put an X in the id column. </p>"
+msgstr ""
+
+#: ../templates/classic/html/_generic.index.html:44
+#: ../templates/minimal/html/_generic.index.html:44
+msgid "Edit Items"
+msgstr ""
+
+#: ../templates/classic/html/file.index.html:4
+msgid "List of files - ${tracker}"
+msgstr ""
+
+#: ../templates/classic/html/file.index.html:5
+msgid "List of files"
+msgstr ""
+
+#: ../templates/classic/html/file.index.html:10
+msgid "Download"
+msgstr ""
+
+#: ../templates/classic/html/file.index.html:11
+#: ../templates/classic/html/file.item.html:22
+msgid "Content Type"
+msgstr ""
+
+#: ../templates/classic/html/file.index.html:12
+msgid "Uploaded By"
+msgstr ""
+
+#: ../templates/classic/html/file.index.html:13
+#: ../templates/classic/html/msg.item.html:43
+msgid "Date"
+msgstr ""
+
+#: ../templates/classic/html/file.item.html:2
+msgid "File display - ${tracker}"
+msgstr ""
+
+#: ../templates/classic/html/file.item.html:4
+msgid "File display"
+msgstr ""
+
+#: ../templates/classic/html/file.item.html:18
+#: ../templates/classic/html/user.item.html:39
+#: ../templates/classic/html/user.register.html:17
+msgid "Name"
+msgstr ""
+
+#: ../templates/classic/html/file.item.html:40
+msgid "download"
+msgstr ""
+
+#: ../templates/classic/html/home.classlist.html:2
+#: ../templates/minimal/html/home.classlist.html:2
+msgid "List of classes - ${tracker}"
+msgstr ""
+
+#: ../templates/classic/html/home.classlist.html:4
+#: ../templates/minimal/html/home.classlist.html:4
+msgid "List of classes"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:7
+msgid "List of issues - ${tracker}"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:11
+msgid "List of issues"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:22
+#: ../templates/classic/html/issue.item.html:44
+msgid "Priority"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:23
+msgid "ID"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:24
+msgid "Creation"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:25
+msgid "Activity"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:26
+msgid "Actor"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:27
+msgid "Topic"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:28
+#: ../templates/classic/html/issue.item.html:39
+msgid "Title"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:29
+#: ../templates/classic/html/issue.item.html:46
+msgid "Status"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:30
+msgid "Creator"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:31
+msgid "Assigned&nbsp;To"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:97
+msgid "Download as CSV"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:105
+msgid "Sort on:"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:108
+#: ../templates/classic/html/issue.index.html:125
+msgid "- nothing -"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:116
+#: ../templates/classic/html/issue.index.html:133
+msgid "Descending:"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:122
+msgid "Group on:"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:139
+msgid "Redisplay"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:7
+msgid "Issue ${id}: ${title} - ${tracker}"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:10
+msgid "New Issue - ${tracker}"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:14
+msgid "New Issue"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:16
+msgid "New Issue Editing"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:19
+msgid "Issue${id}"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:22
+msgid "Issue${id} Editing"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:51
+msgid "Superseder"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:56
+msgid "View: ${link}"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:60
+msgid "Nosy List"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:69
+msgid "Assigned To"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:71
+msgid "Topics"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:79
+msgid "Change Note"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:87
+msgid "File"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:99
+msgid "Make a copy"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:107
+#: ../templates/classic/html/user.item.html:106
+#: ../templates/classic/html/user.register.html:69
+#: ../templates/minimal/html/user.item.html:86
+msgid ""
+"<table class=\"form\"> <tr> <td>Note:&nbsp;</td> <th class=\"required"
+"\">highlighted</th> <td>&nbsp;fields are required.</td> </tr> </table>"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:121
+msgid ""
+"Created on <b>${creation}</b> by <b>${creator}</b>, last changed <b>"
+"${activity}</b> by <b>${actor}</b>."
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:125
+#: ../templates/classic/html/msg.item.html:56
+msgid "Files"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:127
+#: ../templates/classic/html/msg.item.html:58
+msgid "File name"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:128
+#: ../templates/classic/html/msg.item.html:59
+msgid "Uploaded"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:129
+msgid "Type"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:130
+#: ../templates/classic/html/query.edit.html:30
+msgid "Edit"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:131
+msgid "Remove"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:151
+#: ../templates/classic/html/issue.item.html:172
+#: ../templates/classic/html/query.edit.html:50
+msgid "remove"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:158
+#: ../templates/classic/html/msg.index.html:9
+msgid "Messages"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:162
+msgid "msg${id} (view)"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:163
+msgid "Author: ${author}"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:165
+msgid "Date: ${date}"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:2
+msgid "Issue searching - ${tracker}"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:4
+msgid "Issue searching"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:25
+msgid "Filter on"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:26
+msgid "Display"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:27
+msgid "Sort on"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:28
+msgid "Group on"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:32
+msgid "All text*:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:40
+msgid "Title:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:50
+msgid "Topic:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:58
+msgid "ID:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:66
+msgid "Creation Date:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:77
+msgid "Creator:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:79
+msgid "created by me"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:88
+msgid "Activity:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:99
+msgid "Actor:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:101
+msgid "done by me"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:112
+msgid "Priority:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:114
+#: ../templates/classic/html/issue.search.html:130
+msgid "not selected"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:125
+msgid "Status:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:128
+msgid "not resolved"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:143
+msgid "Assigned to:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:146
+msgid "assigned to me"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:148
+msgid "unassigned"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:158
+msgid "No Sort or group:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:166
+msgid "Pagesize:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:172
+msgid "Start With:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:178
+msgid "Sort Descending:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:185
+msgid "Group Descending:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:192
+msgid "Query name**:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:204
+#: ../templates/classic/html/page.html:31
+#: ../templates/classic/html/page.html:60
+#: ../templates/minimal/html/page.html:31
+msgid "Search"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:209
+msgid "*: The \"all text\" field will look in message bodies and issue titles"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:212
+msgid ""
+"**: If you supply a name, the query will be saved off and available as a link "
+"in the sidebar"
+msgstr ""
+
+#: ../templates/classic/html/keyword.item.html:3
+msgid "Keyword editing - ${tracker}"
+msgstr ""
+
+#: ../templates/classic/html/keyword.item.html:5
+msgid "Keyword editing"
+msgstr ""
+
+#: ../templates/classic/html/keyword.item.html:11
+msgid "Existing Keywords"
+msgstr ""
+
+#: ../templates/classic/html/keyword.item.html:20
+msgid ""
+"To edit an existing keyword (for spelling or typing errors), click on its "
+"entry above."
+msgstr ""
+
+#: ../templates/classic/html/keyword.item.html:27
+msgid "To create a new keyword, enter it below and click \"Submit New Entry\"."
+msgstr ""
+
+#: ../templates/classic/html/keyword.item.html:37
+msgid "Keyword"
+msgstr ""
+
+#: ../templates/classic/html/msg.index.html:3
+msgid "List of messages - ${tracker}"
+msgstr ""
+
+#: ../templates/classic/html/msg.index.html:5
+msgid "Message listing"
+msgstr ""
+
+#: ../templates/classic/html/msg.item.html:6
+msgid "Message ${id} - ${tracker}"
+msgstr ""
+
+#: ../templates/classic/html/msg.item.html:9
+msgid "New Message - ${tracker}"
+msgstr ""
+
+#: ../templates/classic/html/msg.item.html:13
+msgid "New Message"
+msgstr ""
+
+#: ../templates/classic/html/msg.item.html:15
+msgid "New Message Editing"
+msgstr ""
+
+#: ../templates/classic/html/msg.item.html:18
+msgid "Message${id}"
+msgstr ""
+
+#: ../templates/classic/html/msg.item.html:21
+msgid "Message${id} Editing"
+msgstr ""
+
+#: ../templates/classic/html/msg.item.html:33
+msgid "Author"
+msgstr ""
+
+#: ../templates/classic/html/msg.item.html:38
+msgid "Recipients"
+msgstr ""
+
+#: ../templates/classic/html/msg.item.html:49
+msgid "Content"
+msgstr ""
+
+#: ../templates/classic/html/page.html:41
+msgid "<b>Your Queries</b> (<a href=\"query?@template=edit\">edit</a>)"
+msgstr ""
+
+#: ../templates/classic/html/page.html:52
+msgid "Issues"
+msgstr ""
+
+#: ../templates/classic/html/page.html:54
+#: ../templates/classic/html/page.html:74
+msgid "Create New"
+msgstr ""
+
+#: ../templates/classic/html/page.html:56
+msgid "Show Unassigned"
+msgstr ""
+
+#: ../templates/classic/html/page.html:58
+msgid "Show All"
+msgstr ""
+
+#: ../templates/classic/html/page.html:61
+msgid "Show issue:"
+msgstr ""
+
+#: ../templates/classic/html/page.html:72
+msgid "Keywords"
+msgstr ""
+
+#: ../templates/classic/html/page.html:78
+msgid "Edit Existing"
+msgstr ""
+
+#: ../templates/classic/html/page.html:84
+#: ../templates/minimal/html/page.html:65
+msgid "Administration"
+msgstr ""
+
+#: ../templates/classic/html/page.html:86
+#: ../templates/minimal/html/page.html:66
+msgid "Class List"
+msgstr ""
+
+#: ../templates/classic/html/page.html:90
+#: ../templates/minimal/html/page.html:68
+msgid "User List"
+msgstr ""
+
+#: ../templates/classic/html/page.html:92
+#: ../templates/minimal/html/page.html:71
+msgid "Add User"
+msgstr ""
+
+#: ../templates/classic/html/page.html:99
+#: ../templates/classic/html/page.html:105
+#: ../templates/minimal/html/page.html:46
+msgid "Login"
+msgstr ""
+
+#: ../templates/classic/html/page.html:104
+#: ../templates/minimal/html/page.html:45
+msgid "Remember me?"
+msgstr ""
+
+#: ../templates/classic/html/page.html:108
+#: ../templates/classic/html/user.register.html:63
+#: ../templates/minimal/html/page.html:50
+#: ../templates/minimal/html/user.register.html:58
+msgid "Register"
+msgstr ""
+
+#: ../templates/classic/html/page.html:111
+msgid "Lost&nbsp;your&nbsp;login?"
+msgstr ""
+
+#: ../templates/classic/html/page.html:116
+msgid "Hello, ${user}"
+msgstr ""
+
+#: ../templates/classic/html/page.html:118
+msgid "Your Issues"
+msgstr ""
+
+#: ../templates/classic/html/page.html:119
+#: ../templates/minimal/html/page.html:57
+msgid "Your Details"
+msgstr ""
+
+#: ../templates/classic/html/page.html:121
+#: ../templates/minimal/html/page.html:59
+msgid "Logout"
+msgstr ""
+
+#: ../templates/classic/html/page.html:125
+msgid "Help"
+msgstr ""
+
+#: ../templates/classic/html/page.html:126
+msgid "Roundup docs"
+msgstr ""
+
+#: ../templates/classic/html/page.html:136
+#: ../templates/minimal/html/page.html:81
+msgid "clear this message"
+msgstr ""
+
+#: ../templates/classic/html/page.html:181
+msgid "don't care"
+msgstr ""
+
+#: ../templates/classic/html/page.html:183
+msgid "------------"
+msgstr ""
+
+#: ../templates/classic/html/page.html:210
+msgid "no value"
+msgstr ""
+
+#: ../templates/classic/html/query.edit.html:4
+msgid "\"Your Queries\" Editing - ${tracker}"
+msgstr ""
+
+#: ../templates/classic/html/query.edit.html:6
+msgid "\"Your Queries\" Editing"
+msgstr ""
+
+#: ../templates/classic/html/query.edit.html:11
+msgid "You are not allowed to edit queries."
+msgstr ""
+
+#: ../templates/classic/html/query.edit.html:28
+msgid "Query"
+msgstr ""
+
+#: ../templates/classic/html/query.edit.html:29
+msgid "Include in \"Your Queries\""
+msgstr ""
+
+#: ../templates/classic/html/query.edit.html:31
+msgid "Private to you?"
+msgstr ""
+
+#: ../templates/classic/html/query.edit.html:44
+msgid "leave out"
+msgstr ""
+
+#: ../templates/classic/html/query.edit.html:45
+msgid "include"
+msgstr ""
+
+#: ../templates/classic/html/query.edit.html:49
+msgid "leave in"
+msgstr ""
+
+#: ../templates/classic/html/query.edit.html:54
+msgid "[query is retired]"
+msgstr ""
+
+#: ../templates/classic/html/query.edit.html:67
+#: ../templates/classic/html/query.edit.html:92
+msgid "edit"
+msgstr ""
+
+#: ../templates/classic/html/query.edit.html:71
+msgid "yes"
+msgstr ""
+
+#: ../templates/classic/html/query.edit.html:73
+msgid "no"
+msgstr ""
+
+#: ../templates/classic/html/query.edit.html:79
+msgid "Delete"
+msgstr ""
+
+#: ../templates/classic/html/query.edit.html:94
+msgid "[not yours to edit]"
+msgstr ""
+
+#: ../templates/classic/html/query.edit.html:102
+msgid "Save Selection"
+msgstr ""
+
+#: ../templates/classic/html/user.forgotten.html:3
+msgid "Password reset request - ${tracker}"
+msgstr ""
+
+#: ../templates/classic/html/user.forgotten.html:5
+msgid "Password reset request"
+msgstr ""
+
+#: ../templates/classic/html/user.forgotten.html:9
+msgid ""
+"You have two options if you have forgotten your password. If you know the "
+"email address you registered with, enter it below."
+msgstr ""
+
+#: ../templates/classic/html/user.forgotten.html:16
+msgid "Email Address:"
+msgstr ""
+
+#: ../templates/classic/html/user.forgotten.html:24
+#: ../templates/classic/html/user.forgotten.html:34
+msgid "Request password reset"
+msgstr ""
+
+#: ../templates/classic/html/user.forgotten.html:30
+msgid "Or, if you know your username, then enter it below."
+msgstr ""
+
+#: ../templates/classic/html/user.forgotten.html:33
+msgid "Username:"
+msgstr ""
+
+#: ../templates/classic/html/user.forgotten.html:39
+msgid ""
+"A confirmation email will be sent to you - please follow the instructions "
+"within it to complete the reset process."
+msgstr ""
+
+#: ../templates/classic/html/user.index.html:3
+#: ../templates/minimal/html/user.index.html:3
+msgid "User listing - ${tracker}"
+msgstr ""
+
+#: ../templates/classic/html/user.index.html:5
+#: ../templates/minimal/html/user.index.html:5
+msgid "User listing"
+msgstr ""
+
+#: ../templates/classic/html/user.index.html:14
+#: ../templates/minimal/html/user.index.html:14
+msgid "Username"
+msgstr ""
+
+#: ../templates/classic/html/user.index.html:15
+msgid "Real name"
+msgstr ""
+
+#: ../templates/classic/html/user.index.html:16
+#: ../templates/classic/html/user.item.html:70
+#: ../templates/classic/html/user.register.html:45
+msgid "Organisation"
+msgstr ""
+
+#: ../templates/classic/html/user.index.html:17
+#: ../templates/minimal/html/user.index.html:15
+msgid "Email address"
+msgstr ""
+
+#: ../templates/classic/html/user.index.html:18
+msgid "Phone number"
+msgstr ""
+
+#: ../templates/classic/html/user.index.html:19
+msgid "Retire"
+msgstr ""
+
+#: ../templates/classic/html/user.index.html:32
+msgid "retire"
+msgstr ""
+
+#: ../templates/classic/html/user.item.html:7
+#: ../templates/minimal/html/user.item.html:7
+msgid "User ${id}: ${title} - ${tracker}"
+msgstr ""
+
+#: ../templates/classic/html/user.item.html:10
+#: ../templates/minimal/html/user.item.html:10
+msgid "New User - ${tracker}"
+msgstr ""
+
+#: ../templates/classic/html/user.item.html:14
+#: ../templates/minimal/html/user.item.html:14
+msgid "New User"
+msgstr ""
+
+#: ../templates/classic/html/user.item.html:16
+#: ../templates/minimal/html/user.item.html:16
+msgid "New User Editing"
+msgstr ""
+
+#: ../templates/classic/html/user.item.html:19
+#: ../templates/minimal/html/user.item.html:19
+msgid "User${id}"
+msgstr ""
+
+#: ../templates/classic/html/user.item.html:22
+#: ../templates/minimal/html/user.item.html:22
+msgid "User${id} Editing"
+msgstr ""
+
+#: ../templates/classic/html/user.item.html:43
+#: ../templates/classic/html/user.register.html:21
+#: ../templates/minimal/html/user.item.html:40
+#: ../templates/minimal/html/user.register.html:26
+msgid "Login Name"
+msgstr ""
+
+#: ../templates/classic/html/user.item.html:47
+#: ../templates/classic/html/user.register.html:25
+#: ../templates/minimal/html/user.item.html:44
+#: ../templates/minimal/html/user.register.html:30
+msgid "Login Password"
+msgstr ""
+
+#: ../templates/classic/html/user.item.html:51
+#: ../templates/classic/html/user.register.html:29
+#: ../templates/minimal/html/user.item.html:48
+#: ../templates/minimal/html/user.register.html:34
+msgid "Confirm Password"
+msgstr ""
+
+#: ../templates/classic/html/user.item.html:55
+#: ../templates/classic/html/user.register.html:33
+#: ../templates/minimal/html/user.item.html:52
+#: ../templates/minimal/html/user.register.html:38
+msgid "Roles"
+msgstr ""
+
+#: ../templates/classic/html/user.item.html:61
+#: ../templates/minimal/html/user.item.html:58
+msgid "(to give the user more than one role, enter a comma,separated,list)"
+msgstr ""
+
+#: ../templates/classic/html/user.item.html:66
+#: ../templates/classic/html/user.register.html:41
+msgid "Phone"
+msgstr ""
+
+#: ../templates/classic/html/user.item.html:74
+msgid "Timezone"
+msgstr ""
+
+#: ../templates/classic/html/user.item.html:78
+msgid "(this is a numeric hour offset, the default is ${zone})"
+msgstr ""
+
+#: ../templates/classic/html/user.item.html:83
+#: ../templates/classic/html/user.register.html:49
+#: ../templates/minimal/html/user.item.html:63
+#: ../templates/minimal/html/user.register.html:46
+msgid "E-mail address"
+msgstr ""
+
+#: ../templates/classic/html/user.item.html:91
+#: ../templates/classic/html/user.register.html:53
+#: ../templates/minimal/html/user.item.html:71
+#: ../templates/minimal/html/user.register.html:50
+msgid "Alternate E-mail addresses<br>One address per line"
+msgstr ""
+
+#: ../templates/classic/html/user.register.html:4
+#: ../templates/classic/html/user.register.html:7
+#: ../templates/minimal/html/user.register.html:4
+#: ../templates/minimal/html/user.register.html:7
+msgid "Registering with ${tracker}"
+msgstr ""
+
+#: ../templates/classic/html/user.rego_progress.html:4
+#: ../templates/minimal/html/user.rego_progress.html:4
+msgid "Registration in progress - ${tracker}"
+msgstr ""
+
+#: ../templates/classic/html/user.rego_progress.html:6
+#: ../templates/minimal/html/user.rego_progress.html:6
+msgid "Registration in progress..."
+msgstr ""
+
+#: ../templates/classic/html/user.rego_progress.html:10
+#: ../templates/minimal/html/user.rego_progress.html:10
+msgid ""
+"You will shortly receive an email to confirm your registration. To complete "
+"the registration process, visit the link indicated in the email."
+msgstr ""
+
+#: ../templates/minimal/html/home.html:2
+msgid "Tracker home - ${tracker}"
+msgstr ""
+
+#: ../templates/minimal/html/home.html:4
+msgid "Tracker home"
+msgstr ""
+
+#: ../templates/minimal/html/home.html:16
+msgid "Please select from one of the menu options on the left."
+msgstr ""
+
+#: ../templates/minimal/html/home.html:19
+msgid "Please log in or register."
+msgstr ""
+
+#: ../templates/minimal/html/page.html:55
+msgid "Hello,<br>${user}"
+msgstr ""

Added: tracker/vendor/roundup/current/locale/ru.po
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/locale/ru.po	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,3135 @@
+# Russian message file for Roundup Issue Tracker
+# alexander smishlajev <alex at tycobka.lv>, 2004
+#
+# $Id: ru.po,v 1.13 2006/03/04 08:51:32 a1s Exp $
+#
+# roundup.pot revision 1.17
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Roundup 0.9.0\n"
+"Report-Msgid-Bugs-To: roundup-devel at lists.sourceforge.net\n"
+"POT-Creation-Date: 2006-02-28 07:44+0200\n"
+"PO-Revision-Date: 2006-03-04 10:42+0200\n"
+"Last-Translator: alexander smishlajev <alex at tycobka.lv>\n"
+"Language-Team: Russian\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=koi8-r\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%"
+"10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
+
+#: ../roundup/admin.py:85 ../roundup/admin.py:979 ../roundup/admin.py:1028
+#: ../roundup/admin.py:1050
+#, python-format
+msgid "no such class \"%(classname)s\""
+msgstr "ëÌÁÓÓ \"%(classname)s\" ÎÅ ÓÕÝÅÓÔ×ÕÅÔ"
+
+#: ../roundup/admin.py:95 ../roundup/admin.py:99
+#, python-format
+msgid "argument \"%(arg)s\" not propname=value"
+msgstr "ÁÒÇÕÍÅÎÔ \"%(arg)s\" ÄÏÌÖÅÎ ÉÍÅÔØ ×ÉÄ ÉÍÑ=ÚÎÁÞÅÎÉÅ"
+
+#: ../roundup/admin.py:112
+#, python-format
+msgid ""
+"Problem: %(message)s\n"
+"\n"
+msgstr ""
+"ïÛÉÂËÁ: %(message)s\n"
+"\n"
+
+#: ../roundup/admin.py:113
+#, python-format
+msgid ""
+"%(message)sUsage: roundup-admin [options] [<command> <arguments>]\n"
+"\n"
+"Options:\n"
+" -i instance home  -- specify the issue tracker \"home directory\" to "
+"administer\n"
+" -u                -- the user[:password] to use for commands\n"
+" -d                -- print full designators not just class id numbers\n"
+" -c                -- when outputting lists of data, comma-separate them.\n"
+"                      Same as '-S \",\"'.\n"
+" -S <string>       -- when outputting lists of data, string-separate them\n"
+" -s                -- when outputting lists of data, space-separate them.\n"
+"                      Same as '-S \" \"'.\n"
+"\n"
+" Only one of -s, -c or -S can be specified.\n"
+"\n"
+"Help:\n"
+" roundup-admin -h\n"
+" roundup-admin help                       -- this help\n"
+" roundup-admin help <command>             -- command-specific help\n"
+" roundup-admin help all                   -- all available help\n"
+msgstr ""
+"%(message)s÷ÙÚÏ×: roundup-admin [ËÌÀÞÉ] [<ËÏÍÁÎÄÁ> <ÁÒÇÕÍÅÎÔÙ>]\n"
+"\n"
+"ëÌÀÞÉ:\n"
+" -i <ËÁÔÁÌÏÇ>  -- ÕËÁÚÙ×ÁÅÔ \"ÄÏÍÁÛÎÉÊ ËÁÔÁÌÏÇ\" ÔÒÅËÅÒÁ.\n"
+" -u            -- ÉÍÑ ÐÏÌØÚÏ×ÁÔÅÌÑ.  íÏÖÎÏ ÕËÁÚÁÔØ ÐÁÒÏÌØ ÞÅÒÅÚ Ä×ÏÅÔÏÞÉÅ.\n"
+" -d            -- ×ÍÅÓÔÏ ÉÄÅÎÔÉÆÉËÁÔÏÒÏ× ×ÙÄÁ×ÁÔØ ÏÐÉÓÁÔÅÌÉ ÏÂßÅËÔÏ×.\n"
+" -c            -- ÐÒÉ ×ÙÄÁÞÅ ÓÐÉÓËÏ× ÒÁÚÄÅÌÑÔØ ÚÎÁÞÅÎÉÑ ÚÁÐÑÔÙÍÉ.\n"
+"                  ôÏ ÖÅ, ÞÔÏ '-S \",\"'.\n"
+" -S <ÓÔÒÏËÁ>   -- ÐÒÉ ×ÙÄÁÞÅ ÓÐÉÓËÏ× ÒÁÚÄÅÌÑÔØ ÚÎÁÞÅÎÉÑ ÕËÁÚÁÎÎÏÊ ÓÔÒÏËÏÊ.\n"
+" -s            -- ÐÒÉ ×ÙÄÁÞÅ ÓÐÉÓËÏ× ÒÁÚÄÅÌÑÔØ ÚÎÁÞÅÎÉÑ ÐÒÏÂÅÌÁÍÉ.\n"
+"                  ôÏ ÖÅ, ÞÔÏ '-S \" \"'.\n"
+"\n"
+" ïÄÎÏ×ÒÅÍÅÎÎÏ ÍÏÖÎÏ ÉÓÐÏÌØÚÏ×ÁÔØ ÔÏÌØËÏ ÏÄÉÎ ÉÚ ËÌÀÞÅÊ -s, -c É -S.\n"
+"\n"
+"óÐÒÁ×ËÉ:\n"
+" roundup-admin -h\n"
+" roundup-admin help                       -- ÜÔÏ ÓÏÏÂÝÅÎÉÅ\n"
+" roundup-admin help <command>             -- ÓÐÒÁ×ËÁ ÐÏ ËÏÍÁÎÄÅ\n"
+" roundup-admin help all                   -- ×ÓÅ ÓÐÒÁ×ÏÞÎÙÅ ÓÏÏÂÝÅÎÉÑ\n"
+
+#: ../roundup/admin.py:138
+msgid "Commands:"
+msgstr "ëÏÍÁÎÄÙ:"
+
+#: ../roundup/admin.py:145
+msgid ""
+"Commands may be abbreviated as long as the abbreviation\n"
+"matches only one command, e.g. l == li == lis == list."
+msgstr ""
+"íÏÖÎÏ ÉÓÐÏÌØÚÏ×ÁÔØ ÔÏÌØËÏ ÎÁÞÁÌØÎÙÅ ÂÕË×Ù ÉÍÅÎÉ ËÏÍÁÎÄÙ,\n"
+"ÅÓÌÉ ÜÔÉÈ ÂÕË× ÄÏÓÔÁÔÏÞÎÏ ÄÌÑ ÏÐÒÅÄÅÌÅÎÉÑ ËÏÍÁÎÄÙ.\n"
+"îÁÐÒÉÍÅÒ, l, li É lis ×ÙÚÙ×ÁÀÔ ËÏÍÁÎÄÕ list."
+
+# ÐÒÏÛÕ ÐÒÏÝÅÎÉÑ, ÎÅ ÍÏÇÕ ÐÒÉÄÕÍÁÔØ, ËÁË ÐÏ-ÒÕÓÓËÉ ÎÁÚÙ×ÁÅÔÓÑ
+# backslash escape.  Ñ ÎÁÐÉÓÁÌ "ÚÁÜËÒÁÎÉÒÏ×ÁÔØ ÏÂÒÁÔÎÏÊ ËÏÓÏÊ ÞÅÒÔÏÊ",
+# ÎÏ ÍÎÅ ÜÔÏ ÓÏ×ÓÅÍ ÎÅ ÎÒÁ×ÉÔÓÑ.
+#  
+# ÞÔÏ ÌÕÞÛÅ ÎÁÐÉÓÁÔØ ×ÍÅÓÔÏ "××ÅÓÔÉ Ó ÔÅÒÍÉÎÁÌÁ"?
+#: ../roundup/admin.py:175
+msgid ""
+"\n"
+"All commands (except help) require a tracker specifier. This is just\n"
+"the path to the roundup tracker you're working with. A roundup tracker\n"
+"is where roundup keeps the database and configuration file that defines\n"
+"an issue tracker. It may be thought of as the issue tracker's \"home\n"
+"directory\". It may be specified in the environment variable TRACKER_HOME\n"
+"or on the command line as \"-i tracker\".\n"
+"\n"
+"A designator is a classname and a nodeid concatenated, eg. bug1, "
+"user10, ...\n"
+"\n"
+"Property values are represented as strings in command arguments and in the\n"
+"printed results:\n"
+" . Strings are, well, strings.\n"
+" . Date values are printed in the full date format in the local time zone,\n"
+"   and accepted in the full format or any of the partial formats explained\n"
+"   below.\n"
+" . Link values are printed as node designators. When given as an argument,\n"
+"   node designators and key strings are both accepted.\n"
+" . Multilink values are printed as lists of node designators joined\n"
+"   by commas.  When given as an argument, node designators and key\n"
+"   strings are both accepted; an empty string, a single node, or a list\n"
+"   of nodes joined by commas is accepted.\n"
+"\n"
+"When property values must contain spaces, just surround the value with\n"
+"quotes, either ' or \". A single space may also be backslash-quoted. If a\n"
+"value must contain a quote character, it must be backslash-quoted or inside\n"
+"quotes. Examples:\n"
+"           hello world      (2 tokens: hello, world)\n"
+"           \"hello world\"    (1 token: hello world)\n"
+"           \"Roch'e\" Compaan (2 tokens: Roch'e Compaan)\n"
+"           Roch\\'e Compaan  (2 tokens: Roch'e Compaan)\n"
+"           address=\"1 2 3\"  (1 token: address=1 2 3)\n"
+"           \\\\               (1 token: \\)\n"
+"           \\n\\r\\t           (1 token: a newline, carriage-return and "
+"tab)\n"
+"\n"
+"When multiple nodes are specified to the roundup get or roundup set\n"
+"commands, the specified properties are retrieved or set on all the listed\n"
+"nodes.\n"
+"\n"
+"When multiple results are returned by the roundup get or roundup find\n"
+"commands, they are printed one per line (default) or joined by commas (with\n"
+"the -c) option.\n"
+"\n"
+"Where the command changes data, a login name/password is required. The\n"
+"login may be specified as either \"name\" or \"name:password\".\n"
+" . ROUNDUP_LOGIN environment variable\n"
+" . the -u command-line option\n"
+"If either the name or password is not supplied, they are obtained from the\n"
+"command-line.\n"
+"\n"
+"Date format examples:\n"
+"  \"2000-04-17.03:45\" means <Date 2000-04-17.08:45:00>\n"
+"  \"2000-04-17\" means <Date 2000-04-17.00:00:00>\n"
+"  \"01-25\" means <Date yyyy-01-25.00:00:00>\n"
+"  \"08-13.22:13\" means <Date yyyy-08-14.03:13:00>\n"
+"  \"11-07.09:32:43\" means <Date yyyy-11-07.14:32:43>\n"
+"  \"14:25\" means <Date yyyy-mm-dd.19:25:00>\n"
+"  \"8:47:11\" means <Date yyyy-mm-dd.13:47:11>\n"
+"  \".\" means \"right now\"\n"
+"\n"
+"Command help:\n"
+msgstr ""
+"\n"
+"÷ÓÅ ËÏÍÁÎÄÙ (ËÒÏÍÅ ËÏÍÁÎÄÙ 'help') ÔÒÅÂÕÀÔ ÕËÁÚÁÎÉÑ ÔÒÅËÅÒÁ.\n"
+"÷Ù ÄÏÌÖÎÙ ÓÏÏÂÝÉÔØ ÉÍÑ ËÁÔÁÌÏÇÁ, × ËÏÔÏÒÏÍ roundup ÈÒÁÎÉÔ ÂÁÚÕ ÄÁÎÎÙÈ\n"
+"É ÎÁÓÔÒÏÅÞÎÙÊ ÆÁÊÌ, ÏÐÉÓÙ×ÁÀÝÉÊ ËÏÎÆÉÇÕÒÁÃÉÀ ÔÒÅËÅÒÁ.  üÔÏÔ ËÁÔÁÌÏÇ\n"
+"ÎÁÚÙ×ÁÅÔÓÑ \"ÄÏÍÁÛÎÉÍ ËÁÔÁÌÏÇÏÍ\" ÔÒÅËÅÒÁ.  ðÕÔØ Ë ÜÔÏÍÕ ËÁÔÁÌÏÇÕ ÍÏÖÅÔ\n"
+"ÚÁÄÁ×ÁÔØÓÑ ÐÅÒÅÍÅÎÎÏÊ ÏËÒÕÖÅÎÉÑ TRACKER_HOME ÉÌÉ ËÌÀÞÏÍ ËÏÍÁÎÄÎÏÊ ÓÔÒÏËÉ\n"
+"\"-i <ÐÕÔØ>\".\n"
+"\n"
+"ïÐÉÓÁÔÅÌØ ÏÂßÅËÔÁ ÓÏÓÔÁ×ÌÑÅÔÓÑ ÉÚ ÉÍÅÎÉ ËÌÁÓÓÁ É ÉÄÅÎÔÉÆÉËÁÔÏÒÁ ÏÂßÅËÔÁ\n"
+"îÁÐÒÉÍÅÒ: bug1, user10 ÉÔÐ.\n"
+"\n"
+"úÎÁÞÅÎÉÑ ÁÔÒÉÂÕÔÏ× × ÁÒÇÕÍÅÎÔÁÈ ËÏÍÁÎÄÙ É ÐÒÉ ÐÅÞÁÔÉ ÒÅÚÕÌØÔÁÔÏ×\n"
+"ÐÒÅÄÓÔÁ×ÌÑÀÔÓÑ ÓÔÒÏËÁÍÉ:\n"
+" . óÔÒÏËÏ×ÙÅ ÚÎÁÞÅÎÉÑ - ÓÔÒÏËÉ É ÅÓÔØ.\n"
+" . úÎÁÞÅÎÉÑ ÄÁÔ ÐÅÞÁÔÁÀÔÓÑ × ÍÅÓÔÎÏÍ ÞÁÓÏ×ÏÍ ÐÏÑÓÅ, ÉÓÐÏÌØÚÕÑ ÐÏÌÎÏÅ\n"
+"   ÐÒÅÄÓÔÁ×ÌÅÎÉÅ ÄÁÔÙ.  ÷ ÁÒÇÕÍÅÎÔÁÈ ËÏÍÁÎÄ ÄÁÔÙ ÍÏÇÕÔ ÕËÁÚÙ×ÁÔØÓÑ ÐÏÌÎÙÍ\n"
+"   ÐÒÅÄÓÔÁ×ÌÅÎÉÅÍ ÉÌÉ ÌÀÂÙÍ ÉÚ ÎÉÖÅÏÐÉÓÁÎÎÙÈ ÞÁÓÔÉÞÎÙÈ ÐÒÅÄÓÔÁ×ÌÅÎÉÊ.\n"
+" . úÎÁÞÅÎÉÑ ÓÓÙÌÏË (Link) ÐÅÞÁÔÁÀÔÓÑ × ×ÉÄÅ ÏÐÉÓÁÔÅÌÅÊ ÏÂßÅËÔÏ×.\n"
+"   ÷ ÁÒÇÕÍÅÎÔÁÈ ËÏÍÁÎÄ ÍÏÇÕÔ ÉÓÐÏÌØÚÏ×ÁÔØÓÑ ÏÐÉÓÁÔÅÌÉ ÏÂßÅËÔÏ×\n"
+"   ÉÌÉ ÚÎÁÞÅÎÉÑ ËÌÀÞÅ×ÙÈ ÁÔÒÉÂÕÔÏ×.\n"
+" . íÎÏÖÅÓÔ×ÅÎÎÙÅ ÓÓÙÌËÉ (Multilink) ÐÅÞÁÔÁÀÔÓÑ × ×ÉÄÅ ÓÐÉÓËÁ ÏÐÉÓÁÔÅÌÅÊ\n"
+"   ÏÂßÅËÔÏ×, ÞÅÒÅÚ ÚÁÐÑÔÕÀ.  ÷ ÁÒÇÕÍÅÎÔÁÈ ËÏÍÁÎÄ ÐÒÉÎÉÍÁÀÔÓÑ ËÁË ÏÐÉÓÁÔÅÌÉ\n"
+"   ÏÂßÅËÔÏ×, ÔÁË É ÚÎÁÞÅÎÉÑ ËÌÀÞÅ×ÙÈ ÁÔÒÉÂÕÔÏ×; ÓÐÉÓÏË ÓÓÙÌÏË ÍÏÖÅÔ ÂÙÔØ\n"
+"   ÐÕÓÔÏÊ ÓÔÒÏËÏÊ, ÏÂÏÚÎÁÞÅÎÉÅÍ (ÏÐÉÓÁÔÅÌÅÍ ÉÌÉ ËÌÀÞÅ×ÙÍ ÚÎÁÞÅÎÉÅÍ) ÏÂßÅËÔÁ\n"
+"   ÉÌÉ ÓÐÉÓËÏÍ ÏÂÏÚÎÁÞÅÎÉÊ, ÒÁÚÄÅÌÅÎÎÙÈ ÚÁÐÑÔÙÍÉ.\n"
+"\n"
+"åÓÌÉ × ÚÎÁÞÅÎÉÑÈ ÁÔÒÉÂÕÔÏ× ×ÓÔÒÅÞÁÀÔÓÑ ÐÒÏÂÅÌÙ, ÔÁËÉÅ ÚÎÁÞÅÎÉÑ ÄÏÌÖÎÙ ÂÙÔØ\n"
+"ÚÁËÌÀÞÅÎÙ × ËÁ×ÙÞËÉ (ÏÄÉÎÁÒÎÙÅ ÉÌÉ Ä×ÏÊÎÙÅ - ×ÓÅ ÒÁ×ÎÏ).  ïÄÉÎÏÞÎÙÊ ÐÒÏÂÅÌ\n"
+"ÍÏÖÎÏ \"ÚÁÜËÒÁÎÉÒÏ×ÁÔØ\" ÏÂÒÁÔÎÏÊ ËÏÓÏÊ ÞÅÒÔÏÊ.  åÓÌÉ × ÚÎÁÞÅÎÉÉ "
+"×ÓÔÒÅÞÁÅÔÓÑ\n"
+"ËÁ×ÙÞËÁ, ÏÎÁ ÄÏÌÖÎÁ ÂÙÔØ ÚÁÜËÒÁÎÉÒÏ×ÁÎÁ ÏÂÒÁÔÎÏÊ ËÏÓÏÊ ÞÅÒÔÏÊ.  ðÒÉÍÅÒÙ:\n"
+"           hello world      (2 ÓÌÏ×Á: hello, world)\n"
+"           \"hello world\"    (1 ÓÌÏ×Ï: hello world)\n"
+"           \"Roch'e\" Compaan (2 ÓÌÏ×Á: Roch'e Compaan)\n"
+"           Roch'e Compaan   (2 ÓÌÏ×Á: Roch'e Compaan)\n"
+"           address=\"1 2 3\"  (1 ÓÌÏ×Ï: address=1 2 3)\n"
+"           \\\\               (1 ÓÌÏ×Ï: \\)\n"
+"           \\n\\r\\t           (1 ÓÌÏ×Ï: ÐÅÒÅ×ÏÄ ÓÔÒÏËÉ, ×ÏÚ×ÒÁÔ ËÁÒÅÔËÉ É "
+"ÔÁÂÕÌÑÃÉÑ)\n"
+"\n"
+"åÓÌÉ × ËÏÍÁÎÄÅ get ÉÌÉ set ÕËÁÚÁÎÙ ÎÅÓËÏÌØËÏ ÏÂßÅËÔÏ×, ÚÁÐÒÏÛÅÎÎÙÅ ÁÔÒÉÂÕÔÙ\n"
+"ÂÕÄÕÔ ×ÙÄÁÎÙ ÉÌÉ ÕÓÔÁ×ÎÏ×ÌÅÎÙ ÄÌÑ ËÁÖÄÏÇÏ ÏÂßÅËÔÁ × ÓÐÉÓËÅ.\n"
+"\n"
+"åÓÌÉ ËÏÍÁÎÄÁ get ÉÌÉ find ×ÏÚ×ÒÁÞÁÅÔ ÎÅÓËÏÌØËÏ ÒÅÚÕÌØÔÁÔÏ×, ÏÎÉ ÏÂÙÞÎÏ\n"
+"ÐÅÞÁÔÁÀÔÓÑ ÐÏ ÏÄÎÏÍÕ × ËÁÖÄÏÊ ÓÔÒÏËÅ.  åÓÌÉ ÕËÁÚÁÎ ËÌÀÞ \"-c\", ÒÅÚÕÌØÔÁÔÙ\n"
+"ÐÅÞÁÔÁÀÔÓÑ ÞÅÒÅÚ ÚÁÐÑÔÕÀ.\n"
+"\n"
+"ëÏÍÁÎÄÙ, ÉÚÍÅÎÑÀÝÉÅ ÂÁÚÕ ÄÁÎÎÙÈ, ÔÒÅÂÕÀÔ ÕËÁÚÁÎÉÑ ÉÍÅÎÉ ÐÏÌØÚÏ×ÁÔÅÌÑ\n"
+"É ÐÁÒÏÌÑ.  ïÎÉ ÍÏÇÕÔ ÂÙÔØ ÕËÁÚÁÎÙ × ×ÉÄÅ \"ÉÍÑ\" ÉÌÉ \"ÉÍÑ:ÐÁÒÏÌØ\":\n"
+" . × ÐÅÒÅÍÅÎÎÏÊ ÏËÒÕÖÅÎÉÑ ROUNDUP_LOGIN\n"
+" . × ÐÁÒÁÍÅÔÒÅ ËÌÀÞÁ ËÏÍÁÎÄÎÏÊ ÓÔÒÏËÉ \"-u\"\n"
+"åÓÌÉ ÉÍÑ ÉÌÉ ÐÁÒÏÌØ ÎÅ ÕËÁÚÁÎÙ, ÐÒÏÇÒÁÍÍÁ ÐÏÐÒÏÓÉÔ ××ÅÓÔÉ ÉÈ Ó ÔÅÒÍÉÎÁÌÁ.\n"
+"\n"
+"ðÒÉÍÅÒÙ ÚÁÐÉÓÉ ÄÁÔ:\n"
+"  \"2000-04-17.03:45\" ÏÚÎÁÞÁÅÔ <Date 2000-04-17.08:45:00>\n"
+"  \"2000-04-17\" ÏÚÎÁÞÁÅÔ <Date 2000-04-17.00:00:00>\n"
+"  \"01-25\" ÏÚÎÁÞÁÅÔ <Date yyyy-01-25.00:00:00>\n"
+"  \"08-13.22:13\" ÏÚÎÁÞÁÅÔ <Date yyyy-08-14.03:13:00>\n"
+"  \"11-07.09:32:43\" ÏÚÎÁÞÁÅÔ <Date yyyy-11-07.14:32:43>\n"
+"  \"14:25\" ÏÚÎÁÞÁÅÔ <Date yyyy-mm-dd.19:25:00>\n"
+"  \"8:47:11\" ÏÚÎÁÞÁÅÔ <Date yyyy-mm-dd.13:47:11>\n"
+"  \".\" ÏÚÎÁÞÁÅÔ \"ÓÅÊÞÁÓ\"\n"
+"\n"
+"óÐÒÁ×ËÁ ÐÏ ËÏÍÁÎÄÁÍ:\n"
+
+#: ../roundup/admin.py:238
+#, python-format
+msgid "%s:"
+msgstr ""
+
+#: ../roundup/admin.py:243
+msgid ""
+"Usage: help topic\n"
+"        Give help about topic.\n"
+"\n"
+"        commands  -- list commands\n"
+"        <command> -- help specific to a command\n"
+"        initopts  -- init command options\n"
+"        all       -- all available help\n"
+"        "
+msgstr ""
+"÷ÙÚÏ×: help <ÔÅÍÁ>\n"
+"        ÷ÙÄÁÔØ ÓÐÒÁ×ËÕ ÐÏ ÕËÁÚÁÎÎÏÊ ÔÅÍÅ.\n"
+"\n"
+"        commands  -- ÓÐÉÓÏË ËÏÍÁÎÄ\n"
+"        <ËÏÍÁÎÄÁ> -- ÓÐÒÁ×ËÁ ÐÏ ÕËÁÚÁÎÎÏÊ ËÏÍÁÎÄÅ\n"
+"        initopts  -- ËÌÀÞÉ ËÏÍÁÎÄÙ 'init'\n"
+"        all       -- ×ÓÅ ÓÐÒÁ×ËÉ\n"
+"        "
+
+#: ../roundup/admin.py:266
+#, python-format
+msgid "Sorry, no help for \"%(topic)s\""
+msgstr "é×ÉÎÉÔÅ, ÓÐÒÁ×ËÁ \"%(topic)s\" ÎÅ ÓÕÝÅÓÔ×ÕÅÔ."
+
+#: ../roundup/admin.py:338 ../roundup/admin.py:394
+msgid "Templates:"
+msgstr "ûÁÂÌÏÎÙ:"
+
+#: ../roundup/admin.py:341 ../roundup/admin.py:405
+msgid "Back ends:"
+msgstr "óÅÒ×ÅÒÙ:"
+
+#: ../roundup/admin.py:344
+msgid ""
+"Usage: install [template [backend [admin password [key=val[,key=val]]]]]\n"
+"        Install a new Roundup tracker.\n"
+"\n"
+"        The command will prompt for the tracker home directory\n"
+"        (if not supplied through TRACKER_HOME or the -i option).\n"
+"        The template, backend and admin password may be specified\n"
+"        on the command-line as arguments, in that order.\n"
+"\n"
+"        The last command line argument allows to pass initial values\n"
+"        for config options.  For example, passing\n"
+"        \"web_http_auth=no,rdbms_user=dinsdale\" will override defaults\n"
+"        for options http_auth in section [web] and user in section [rdbms].\n"
+"        Please be careful to not use spaces in this argument! (Enclose\n"
+"        whole argument in quotes if you need spaces in option value).\n"
+"\n"
+"        The initialise command must be called after this command in order\n"
+"        to initialise the tracker's database. You may edit the tracker's\n"
+"        initial database contents before running that command by editing\n"
+"        the tracker's dbinit.py module init() function.\n"
+"\n"
+"        See also initopts help.\n"
+"        "
+msgstr ""
+"÷ÙÚÏ×: install [ÛÁÂÌÏÎ [ÓÅÒ×ÅÒ [ÐÁÒÏÌØ [ËÌÀÞ=ÚÎÁÞÅÎÉÅ[,ËÌÀÞ=ÚÎÁÞÅÎÉÅ]]]]]\n"
+"        õÓÔÁÎÏ×ÉÔØ ÎÏ×ÙÊ ÔÒÅËÅÒ Roundup.\n"
+"\n"
+"        ÷ÁÍ ÎÁÄÏ ÂÕÄÅÔ ÕËÁÚÁÔØ \"ÄÏÍÁÛÎÉÊ ËÁÔÁÌÏÇ\" ÔÒÅËÅÒÁ (ÅÓÌÉ ÏÎ\n"
+"        ÎÅ ÚÁÄÁÎ ÐÅÒÅÍÅÎÎÏÊ ÏËÒÕÖÅÎÉÑ TRACKER_HOME ÉÌÉ ËÌÀÞÏÍ ËÏÍÁÎÄÎÏÊ\n"
+"        ÓÔÒÏËÉ '-i').  ûÁÂÌÏÎ ÔÒÅËÅÒÁ, ÔÉÐ ÂÁÚÙ ÄÁÎÎÙÈ É ÐÁÒÏÌØ\n"
+"        ÁÄÍÉÎÉÓÔÒÁÔÏÒÁ ÍÏÖÎÏ ÕËÁÚÁÔØ × ÐÁÒÁÍÅÔÒÁÈ ËÏÍÁÎÄÎÏÊ ÓÔÒÏËÉ\n"
+"        ÉÌÉ ××ÅÓÔÉ × ÏÔ×ÅÔ ÎÁ ÓÏÏÔ×ÅÔÓÔ×ÕÀÝÉÅ ÐÏÄÓËÁÚËÉ ÐÒÏÇÒÁÍÍÙ.\n"
+"\n"
+"        ðÏÓÌÅÄÎÉÊ ÐÁÒÁÍÅÔÒ ËÏÍÁÎÄÎÏÊ ÓÔÒÏËÉ ÐÏÚ×ÏÌÑÅÔ ÚÁÄÁÔØ ÎÁÞÁÌØÎÙÅ\n"
+"        ÚÎÁÞÅÎÉÑ ÄÌÑ ÆÁÊÌÁ ËÏÎÆÉÇÕÒÁÃÉÉ Roundup.  îÁÐÒÉÍÅÒ, ÓÔÒÏËÁ\n"
+"        \"web_http_auth=no,rdbms_user=dinsdale\" ÚÁÍÅÎÉÔ ÚÎÁÞÅÎÉÅ\n"
+"        ÐÁÒÁÍÅÔÒÁ http_auth × ÓÅËÃÉÉ [web] É ÐÁÒÁÍÅÔÒÁ user × ÓÅËÃÉÉ\n"
+"        [rdbms].  âÕÄØÔÅ ×ÎÉÍÁÔÅÌØÎÙ: ÎÁÓÔÒÏÊËÉ ÎÕÖÎÏ ÕËÁÚÙ×ÁÔØ ÐÏÄÒÑÄ,\n"
+"        ÂÅÚ ÐÒÏÂÅÌÏ×.  åÓÌÉ ÚÎÁÞÅÎÉÅ ÐÁÒÁÍÅÔÒÁ ÎÁÓÔÒÏÊËÉ Roundup ÄÏÌÖÎÏ\n"
+"        ÓÏÄÅÒÖÁÔØ ÐÒÏÂÅÌ, ÚÁËÌÀÞÉÔÅ ×ÅÓØ ÐÁÒÁÍÅÔÒ ËÏÍÁÎÄÎÏÊ ÓÔÒÏËÉ\n"
+"        × ËÁ×ÙÞËÉ.\n"
+"\n"
+"        ðÏÓÌÅ ÜÔÏÊ ËÏÍÁÎÄÙ ÎÕÖÎÏ ×ÙÚ×ÁÔØ ËÏÍÁÎÄÕ 'initialise', ÞÔÏÂÙ\n"
+"        ÓÏÚÄÁÔØ ÂÁÚÕ ÄÁÎÎÙÈ ÔÒÅËÅÒÁ.  ÷Ù ÍÏÖÅÔÅ ÐÒÅÄ×ÁÒÉÔÅÌØÎÏ ÉÚÍÅÎÉÔØ\n"
+"        ÓÈÅÍÕ ÂÁÚÙ ÄÁÎÎÙÈ, ËÏÔÏÒÁÑ ÏÐÉÓÁÎÁ × ÆÕÎËÃÉÉ init() ÍÏÄÕÌÑ\n"
+"        dbinit.py.\n"
+"\n"
+"        óÍ.ÔÁËÖÅ \"help initopts\".\n"
+"        "
+
+#: ../roundup/admin.py:367 ../roundup/admin.py:464 ../roundup/admin.py:525
+#: ../roundup/admin.py:604 ../roundup/admin.py:654 ../roundup/admin.py:712
+#: ../roundup/admin.py:733 ../roundup/admin.py:761 ../roundup/admin.py:832
+#: ../roundup/admin.py:899 ../roundup/admin.py:970 ../roundup/admin.py:1018
+#: ../roundup/admin.py:1040 ../roundup/admin.py:1067 ../roundup/admin.py:1134
+#: ../roundup/admin.py:1204
+msgid "Not enough arguments supplied"
+msgstr "îÅÄÏÓÔÁÔÏÞÎÏ ÁÒÇÕÍÅÎÔÏ×"
+
+#: ../roundup/admin.py:373
+#, python-format
+msgid "Instance home parent directory \"%(parent)s\" does not exist"
+msgstr "ëÁÔÁÌÏÇ \"%(parent)s\" ÎÅ ÓÕÝÅÓÔ×ÕÅÔ"
+
+#: ../roundup/admin.py:381
+#, python-format
+msgid ""
+"WARNING: There appears to be a tracker in \"%(tracker_home)s\"!\n"
+"If you re-install it, you will lose all the data!\n"
+"Erase it? Y/N: "
+msgstr ""
+"÷îéíáîéå: × ËÁÔÁÌÏÇÅ \"%(tracker_home)s\" ÏÂÎÁÒÕÖÅÎ ÓÕÝÅÓÔ×ÕÀÝÉÊ ÔÒÅËÅÒ!\n"
+"ðÏ×ÔÏÒÎÁÑ ÕÓÔÁÎÏ×ËÁ ÕÎÉÞÔÏÖÉÔ ×ÓÅ ×ÁÛÉ ÄÁÎÎÙÅ!\n"
+"õÄÁÌÉÔØ ÓÕÝÅÓÔ×ÕÀÝÉÊ ÔÒÅËÅÒ? Y/N: "
+
+#: ../roundup/admin.py:396
+msgid "Select template [classic]: "
+msgstr "÷ÙÂÅÒÉÔÅ ÛÁÂÌÏÎ [classic]: "
+
+#: ../roundup/admin.py:407
+msgid "Select backend [anydbm]: "
+msgstr "÷ÙÂÅÒÉÔÅ ÓÅÒ×ÅÒ [anydbm]: "
+
+#: ../roundup/admin.py:417
+#, python-format
+msgid "Error in configuration settings: \"%s\""
+msgstr "ïÛÉÂËÁ × ÐÁÒÁÍÅÔÒÁÈ ËÏÎÆÉÇÕÒÁÃÉÉ: \"%s\""
+
+#: ../roundup/admin.py:426
+#, python-format
+msgid ""
+"\n"
+"---------------------------------------------------------------------------\n"
+" You should now edit the tracker configuration file:\n"
+"   %(config_file)s"
+msgstr ""
+"\n"
+"---------------------------------------------------------------------------\n"
+" ôÅÐÅÒØ ×ÁÍ ÎÕÖÎÏ ÉÓÐÒÁ×ÉÔØ ËÏÎÆÉÇÕÒÁÃÉÏÎÎÙÊ ÆÁÊÌ ÔÒÅËÅÒÁ:\n"
+"   %(config_file)s"
+
+#: ../roundup/admin.py:436
+msgid " ... at a minimum, you must set following options:"
+msgstr " ... ËÁË ÍÉÎÉÍÕÍ, ×Ù ÄÏÌÖÎÙ ÕÓÔÁÎÏ×ÉÔØ ÎÁÓÔÒÏÊËÉ:"
+
+# õËÁÚÁÎÏ ÁÎÇÌÉÊÓËÏÅ ÎÁÚ×ÁÎÉÅ ÄÏËÕÍÅÎÔÁ
+#: ../roundup/admin.py:441
+#, python-format
+msgid ""
+"\n"
+" If you wish to modify the database schema,\n"
+" you should also edit the schema file:\n"
+"   %(database_config_file)s\n"
+" You may also change the database initialisation file:\n"
+"   %(database_init_file)s\n"
+" ... see the documentation on customizing for more information.\n"
+"\n"
+" You MUST run the \"roundup-admin initialise\" command once you've "
+"performed\n"
+" the above steps.\n"
+"---------------------------------------------------------------------------\n"
+msgstr ""
+"\n"
+" åÓÌÉ ×Ù ÈÏÔÉÔÅ ÉÚÍÅÎÉÔØ ÓÔÁÎÄÁÒÔÎÕÀ ÓÈÅÍÕ ÂÁÚÙ ÄÁÎÎÙÈ,\n"
+" ×ÎÅÓÉÔÅ ÉÚÍÅÎÅÎÉÑ × ÆÁÊÌ ËÏÎÆÉÇÕÒÁÃÉÉ ÂÁÚÙ:\n"
+"   %(database_config_file)s\n"
+" ÷Ù ÔÁËÖÅ ÍÏÖÅÔÅ ÉÚÍÅÎÉÔØ ÆÁÊÌ ÐÅÒ×ÏÎÁÞÁÌØÎÏÊ ÚÁÇÒÕÚËÉ ÄÁÎÎÙÈ:\n"
+"   %(database_init_file)s\n"
+" ï ÔÏÍ, ËÁË ÜÔÏ ÄÅÌÁÔØ, ÒÁÓÓËÁÚÁÎÏ × ÄÏËÕÍÅÎÔÅ \"Customising Roundup\".\n"
+"\n"
+" ðÏÓÌÅ ÜÔÏÇÏ ×Ù ÄÏÌÖÎÙ ×ÙÐÏÌÎÉÔØ ËÏÍÁÎÄÕ \"roundup-admin initialise\".\n"
+"---------------------------------------------------------------------------\n"
+
+#: ../roundup/admin.py:459
+msgid ""
+"Usage: genconfig <filename>\n"
+"        Generate a new tracker config file (ini style) with default values\n"
+"        in <filename>.\n"
+"        "
+msgstr ""
+"÷ÙÚÏ×: genconfig <ÉÍÑ ÆÁÊÌÁ>\n"
+"        óÏÚÄÁÔØ ÎÏ×ÙÊ ËÏÎÆÉÇÕÒÁÃÉÏÎÎÙÊ ÆÁÊÌ ÔÒÅËÅÒÁ,\n"
+"        ÉÓÐÏÌØÚÕÑ ÎÁÓÔÒÏÊËÉ ÐÏ ÕÍÏÌÞÁÎÉÀ.\n"
+"        "
+
+#  password
+#. password
+#: ../roundup/admin.py:469
+msgid ""
+"Usage: initialise [adminpw]\n"
+"        Initialise a new Roundup tracker.\n"
+"\n"
+"        The administrator details will be set at this step.\n"
+"\n"
+"        Execute the tracker's initialisation function dbinit.init()\n"
+"        "
+msgstr ""
+"÷ÙÚÏ×: initialise [ÐÁÒÏÌØ]\n"
+"        ðÒÏÉÎÉÃÉÁÌÉÚÉÒÏ×ÁÔØ ÎÏ×ÙÊ ÔÒÅËÅÒ Roundup.\n"
+"\n"
+"        îÁ ÜÔÏÍ ÛÁÇÅ ÚÁÐÏÌÎÑÅÔÓÑ ÕÞÅÔÎÁÑ ËÁÒÔÏÞËÁ ÁÄÍÉÎÉÓÔÒÁÔÏÒÁ.\n"
+"\n"
+"        éÎÉÃÉÁÌÉÚÁÃÉÑ ÔÒÅËÅÒÁ ÄÅÌÁÅÔÓÑ ÆÕÎËÃÉÅÊ dbinit.init()\n"
+"        "
+
+#: ../roundup/admin.py:483
+msgid "Admin Password: "
+msgstr "ðÁÒÏÌØ ÁÄÍÉÎÉÓÔÒÁÔÏÒÁ: "
+
+#: ../roundup/admin.py:484
+msgid "       Confirm: "
+msgstr "              åÝÅ ÒÁÚ: "
+
+#: ../roundup/admin.py:488
+msgid "Instance home does not exist"
+msgstr "äÏÍÁÛÎÉÊ ËÁÔÁÌÏÇ ÔÒÅËÅÒÁ ÎÅ ÓÕÝÅÓÔ×ÕÅÔ"
+
+#: ../roundup/admin.py:492
+msgid "Instance has not been installed"
+msgstr "ôÒÅËÅÒ ÎÅ ÕÓÔÁÎÏ×ÌÅÎ"
+
+#: ../roundup/admin.py:497
+msgid ""
+"WARNING: The database is already initialised!\n"
+"If you re-initialise it, you will lose all the data!\n"
+"Erase it? Y/N: "
+msgstr ""
+"÷îéíáîéå: ÂÁÚÁ ÄÁÎÎÙÈ ÕÖÅ ÂÙÌÁ ÐÒÏÉÎÉÃÉÁÌÉÚÉÒÏ×ÁÎÁ!\n"
+"ðÏ×ÔÏÒÎÁÑ ÉÎÉÃÉÁÌÉÚÁÃÉÑ ÕÎÉÞÔÏÖÉÔ ×ÓÅ ×ÁÛÉ ÄÁÎÎÙÅ!\n"
+"õÄÁÌÉÔØ ÓÕÝÅÓÔ×ÕÀÝÕÀ ÂÁÚÕ? Y/N: "
+
+#: ../roundup/admin.py:518
+msgid ""
+"Usage: get property designator[,designator]*\n"
+"        Get the given property of one or more designator(s).\n"
+"\n"
+"        Retrieves the property value of the nodes specified\n"
+"        by the designators.\n"
+"        "
+msgstr ""
+"÷ÙÚÏ×: get ÁÔÒÉÂÕÔ ÏÐÉÓÁÔÅÌØ[,ÏÐÉÓÁÔÅÌØ]*\n"
+"        ðÏÌÕÞÉÔØ ÚÎÁÞÅÎÉÅ ÕËÁÚÁÎÎÏÇÏ ÁÔÒÉÂÕÔÁ ÏÄÎÏÇÏ ÉÌÉ ÎÅÓËÏÌØËÉÈ\n"
+"        ÏÂßÅËÔÏ×.\n"
+"\n"
+"        ÷ÙÄÁÅÔ ÚÎÁÞÅÎÉÅ ÕËÁÚÁÎÎÏÇÏ ÁÔÒÉÂÕÔÁ ÄÌÑ ×ÓÅÈ ÏÂßÅËÔÏ×,\n"
+"        ÐÅÒÅÞÉÓÌÅÎÎÙÈ × ÓÐÉÓËÅ ÏÐÉÓÁÔÅÌÅÊ.\n"
+"        "
+
+#: ../roundup/admin.py:558 ../roundup/admin.py:573
+#, python-format
+msgid "property %s is not of type Multilink or Link so -d flag does not apply."
+msgstr ""
+"ëÌÀÞ '-d' ÎÅÐÒÉÍÅÎÉÍ, ÐÏÔÏÍÕ ÞÔÏ ÔÉÐ ÁÔÒÉÂÕÔÁ %s - ÎÅ Link É ÎÅ Multilink"
+
+#: ../roundup/admin.py:581 ../roundup/admin.py:981 ../roundup/admin.py:1030
+#: ../roundup/admin.py:1052
+#, python-format
+msgid "no such %(classname)s node \"%(nodeid)s\""
+msgstr "÷ ËÌÁÓÓÅ %(classname)s ÎÅÔ ÏÂßÅËÔÁ \"%(nodeid)s\""
+
+#: ../roundup/admin.py:583
+#, python-format
+msgid "no such %(classname)s property \"%(propname)s\""
+msgstr "õ ËÌÁÓÓÁ %(classname)s ÎÅÔ ÁÔÒÉÂÕÔÁ \"%(propname)s\""
+
+#: ../roundup/admin.py:592
+msgid ""
+"Usage: set items property=value property=value ...\n"
+"        Set the given properties of one or more items(s).\n"
+"\n"
+"        The items are specified as a class or as a comma-separated\n"
+"        list of item designators (ie \"designator[,designator,...]\").\n"
+"\n"
+"        This command sets the properties to the values for all designators\n"
+"        given. If the value is missing (ie. \"property=\") then the "
+"property\n"
+"        is un-set. If the property is a multilink, you specify the linked\n"
+"        ids for the multilink as comma-separated numbers (ie \"1,2,3\").\n"
+"        "
+msgstr ""
+"÷ÙÚÏ×: set items ÁÔÒÉÂÕÔ=ÚÎÁÞÅÎÉÅ ÁÔÒÉÂÕÔ=ÚÎÁÞÅÎÉÅ ...\n"
+"        õÓÔÁÎÏ×ÉÔØ ÕËÁÚÁÎÎÙÅ ÁÔÒÉÂÕÔÙ ÏÄÎÏÇÏ ÉÌÉ ÎÅÓËÏÌØËÉÈ ÏÂßÅËÔÏ×.\n"
+"\n"
+"        ïÂßÅËÔÙ ÚÁÄÁÀÔÓÑ ÉÍÅÎÅÍ ËÌÁÓÓÁ ÉÌÉ ÓÐÉÓËÏÍ ÏÐÉÓÁÔÅÌÅÊ, ÒÁÚÄÅÌÅÎÎÙÈ\n"
+"        ÚÁÐÑÔÙÍÉ.  (îÁÐÒÉÍÅÒ, \"user1,user5\".)\n"
+"\n"
+"        üÔÁ ËÏÍÁÎÄÁ ÐÒÉÓ×ÁÉ×ÁÅÔ ÕËÁÚÁÎÎÙÅ ÚÎÁÞÅÎÉÑ ÁÔÒÉÂÕÔÁÍ ×ÓÅÈ ÕËÁÚÁÎÎÙÈ\n"
+"        ÏÂßÅËÔÏ×.  åÓÌÉ ÚÎÁÞÅÎÉÅ ÎÅ ÕËÁÚÁÎÏ (Ô.Å. ÕËÁÚÁÎÏ \"ÁÔÒÉÂÕÔ=\"),\n"
+"        ÁÔÒÉÂÕÔ ÏÞÉÝÁÅÔÓÑ.  åÓÌÉ ÁÔÒÉÂÕÔ Ñ×ÌÑÅÔÓÑ ÓÐÉÓËÏÍ ÓÓÙÌÏË ÎÁ ÏÂßÅËÔÙ\n"
+"        ÄÒÕÇÏÇÏ ËÌÁÓÓÁ (Multilink), × ÚÎÁÞÅÎÉÉ ÄÏÌÖÎÙ ÂÙÔØ ÞÅÒÅÚ ÚÁÐÑÔÕÀ\n"
+"        ÐÅÒÅÞÉÓÌÅÎÙ ÉÄÅÎÔÉÆÉËÁÔÏÒÙ ÏÂßÅËÔÏ×, ÎÁ ËÏÔÏÒÙÅ ÓÓÙÌÁÅÔÓÑ ÜÔÏÔ\n"
+"        ÁÔÒÉÂÕÔ.  (îÁÐÒÉÍÅÒ, \"1,2,3\".)\n"
+"        "
+
+#: ../roundup/admin.py:646
+msgid ""
+"Usage: find classname propname=value ...\n"
+"        Find the nodes of the given class with a given link property value.\n"
+"\n"
+"        Find the nodes of the given class with a given link property value.\n"
+"        The value may be either the nodeid of the linked node, or its key\n"
+"        value.\n"
+"        "
+msgstr ""
+"÷ÙÚÏ×: find ËÌÁÓÓ ÁÔÒÉÂÕÔ=ÚÎÁÞÅÎÉÅ ...\n"
+"        îÁÊÔÉ ÏÂßÅËÔÙ ËÌÁÓÓÁ Ó ÄÁÎÎÙÍ ÚÎÁÞÅÎÉÅÍ ÓÓÙÌÏÞÎÏÇÏ ÁÔÒÉÂÕÔÁ.\n"
+"\n"
+"        îÁÊÔÉ ÏÂßÅËÔÙ ÕËÁÚÁÎÎÏÇÏ ËÌÁÓÓÁ Ó ÄÁÎÎÙÍ ÚÎÁÞÅÎÉÅÍ ÓÓÙÌÏÞÎÏÇÏ\n"
+"        ÁÔÒÉÂÕÔÁ.  úÎÁÞÅÎÉÅ ÍÏÖÅÔ ÂÙÔØ ÉÄÅÎÔÉÆÉËÁÔÏÒÏÍ ÏÂßÅËÔÁ, ÎÁ\n"
+"        ËÏÔÏÒÙÊ ÓÓÙÌÁÅÔÓÑ ÁÔÒÉÂÕÔ, ÉÌÉ ËÌÀÞÏÍ ÜÔÏÇÏ ÏÂßÅËÔÁ.\n"
+"        "
+
+#: ../roundup/admin.py:699 ../roundup/admin.py:852 ../roundup/admin.py:864
+#: ../roundup/admin.py:918
+#, python-format
+msgid "%(classname)s has no property \"%(propname)s\""
+msgstr "ëÌÁÓÓ %(classname)s ÎÅ ÉÍÅÅÔ ÁÔÒÉÂÕÔÁ \"%(propname)s\""
+
+#: ../roundup/admin.py:706
+msgid ""
+"Usage: specification classname\n"
+"        Show the properties for a classname.\n"
+"\n"
+"        This lists the properties for a given class.\n"
+"        "
+msgstr ""
+"÷ÙÚÏ×: specification ËÌÁÓÓ\n"
+"        ðÏËÁÚÁÔØ ÁÔÒÉÂÕÔÙ ËÌÁÓÓÁ.\n"
+"\n"
+"        ÷ÙÄÁÅÔ ÓÐÉÓÏË ÁÔÒÉÂÕÔÏ× ÕËÁÚÁÎÎÏÇÏ ËÌÁÓÓÁ.\n"
+"        "
+
+#: ../roundup/admin.py:721
+#, python-format
+msgid "%(key)s: %(value)s (key property)"
+msgstr "%(key)s: %(value)s (ËÌÀÞÅ×ÏÊ ÁÔÒÉÂÕÔ)"
+
+#: ../roundup/admin.py:723
+#, python-format
+msgid "%(key)s: %(value)s"
+msgstr ""
+
+#: ../roundup/admin.py:726
+msgid ""
+"Usage: display designator[,designator]*\n"
+"        Show the property values for the given node(s).\n"
+"\n"
+"        This lists the properties and their associated values for the given\n"
+"        node.\n"
+"        "
+msgstr ""
+"÷ÙÚÏ×: display ÏÐÉÓÁÔÅÌØ[,ÏÐÉÓÁÔÅÌØ]*\n"
+"        ðÏËÁÚÁÔØ ÚÎÁÞÅÎÉÑ ÁÔÒÉÂÕÔÏ× ÕËÁÚÁÎÎÙÈ ÏÂßÅËÔÏ×.\n"
+"\n"
+"        ÷ÙÄÁÅÔ ÓÐÉÓÏË ÁÔÒÉÂÕÔÏ× É ÉÈ ÚÎÁÞÅÎÉÊ ÄÌÑ ÏÂßÅËÔÏ×,\n"
+"        ÚÁÄÁÎÎÙÈ ÏÐÉÓÁÔÅÌÑÍÉ.\n"
+"        "
+
+#: ../roundup/admin.py:750
+#, python-format
+msgid "%(key)s: %(value)r"
+msgstr ""
+
+#: ../roundup/admin.py:753
+msgid ""
+"Usage: create classname property=value ...\n"
+"        Create a new entry of a given class.\n"
+"\n"
+"        This creates a new entry of the given class using the property\n"
+"        name=value arguments provided on the command line after the \"create"
+"\"\n"
+"        command.\n"
+"        "
+msgstr ""
+"÷ÙÚÏ×: create ËÌÁÓÓ ÁÔÒÉÂÕÔ=ÚÎÁÞÅÎÉÅ ...\n"
+"        óÏÚÄÁÔØ ÎÏ×ÙÊ ÏÂßÅËÔ ÕËÁÚÁÎÎÏÇÏ ËÌÁÓÓÁ.\n"
+"\n"
+"        óÏÚÄÁÅÔ ÎÏ×ÙÊ ÏÂßÅËÔ ÕËÁÚÁÎÎÏÇÏ ËÌÁÓÓÁ É ÚÁÐÏÌÎÑÅÔ ÁÔÒÉÂÕÔÙ\n"
+"        ÜÔÏÇÏ ÏÂßÅËÔÁ ÕËÁÚÁÎÎÙÍÉ ÚÎÁÞÅÎÉÑÍÉ.\n"
+"        "
+
+#: ../roundup/admin.py:780
+#, python-format
+msgid "%(propname)s (Password): "
+msgstr " %(propname)s (ÐÁÒÏÌØ): "
+
+#: ../roundup/admin.py:782
+#, python-format
+msgid "   %(propname)s (Again): "
+msgstr "%(propname)s (ÅÝÅ ÒÁÚ): "
+
+#: ../roundup/admin.py:784
+msgid "Sorry, try again..."
+msgstr "ðÁÒÏÌÉ ÎÅ ÓÏ×ÐÁÌÉ.  ðÏÐÒÏÂÕÊÔÅ ÅÝÅ ÒÁÚ."
+
+#: ../roundup/admin.py:788
+#, python-format
+msgid "%(propname)s (%(proptype)s): "
+msgstr ""
+
+#: ../roundup/admin.py:806
+#, python-format
+msgid "you must provide the \"%(propname)s\" property."
+msgstr "áÔÒÉÂÕÔ \"%(propname)s\" ÄÏÌÖÅÎ ÂÙÔØ ÚÁÐÏÌÎÅÎ."
+
+#: ../roundup/admin.py:817
+msgid ""
+"Usage: list classname [property]\n"
+"        List the instances of a class.\n"
+"\n"
+"        Lists all instances of the given class. If the property is not\n"
+"        specified, the  \"label\" property is used. The label property is\n"
+"        tried in order: the key, \"name\", \"title\" and then the first\n"
+"        property, alphabetically.\n"
+"\n"
+"        With -c, -S or -s print a list of item id's if no property\n"
+"        specified.  If property specified, print list of that property\n"
+"        for every class instance.\n"
+"        "
+msgstr ""
+"÷ÙÚÏ×: list ËÌÁÓÓ [ÁÔÒÉÂÕÔ]\n"
+"        ÷ÙÄÁÔØ ÓÐÉÓÏË ÏÂßÅËÔÏ× ËÌÁÓÓÁ.\n"
+"\n"
+"        ðÅÞÁÔÁÅÔ ÓÐÉÓÏË ×ÓÅÈ ÏÂßÅËÔÏ× ÕËÁÚÁÎÎÏÇÏ ËÌÁÓÓÁ.\n"
+"        åÓÌÉ ÁÔÒÉÂÕÔ ÎÅ ÕËÁÚÁÎ, ÉÓÐÏÌØÚÕÅÔÓÑ \"ÔÉÔÕÌØÎÙÊ\" ÁÔÒÉÂÕÔ.\n"
+"        ÷ ËÁÞÅÓÔ×Å \"ÔÉÔÕÌØÎÏÇÏ\" ÁÔÒÉÂÕÔÁ ÉÓÐÏÌØÚÕÅÔÓÑ ÐÅÒ×ÙÊ ÎÁÊÄÅÎÎÙÊ\n"
+"        ÉÚ ÓÌÅÄÕÀÝÉÈ ÁÔÒÉÂÕÔÏ×: ËÌÀÞ, \"name\", \"title\" ÉÌÉ ÐÅÒ×ÙÊ\n"
+"        ÉÚ ÁÔÒÉÂÕÔÏ× ËÌÁÓÓÁ × ÁÌÆÁ×ÉÔÎÏÍ ÐÏÒÑÄËÅ.\n"
+"\n"
+"        ó ËÌÀÞÁÍÉ -c, -S ÉÌÉ -s, ÅÓÌÉ ÎÅ ÕËÁÚÁÎÏ ÉÍÑ ÁÔÒÉÂÕÔÁ, ÐÅÞÁÔÁÅÔ\n"
+"        ÓÐÉÓÏË ÉÄÅÎÔÉÆÉËÁÔÏÒÏ× ÏÂßÅËÔÏ×.  åÓÌÉ ÉÍÑ ÁÔÒÉÂÕÔÁ ÕËÁÚÁÎÏ,\n"
+"        ×ÙÄÁÅÔ ÓÐÉÓÏË ÚÎÁÞÅÎÉÊ ÜÔÏÇÏ ÁÔÒÉÂÕÔÁ.\n"
+"        "
+
+#: ../roundup/admin.py:830
+msgid "Too many arguments supplied"
+msgstr "ðÏÄÁÎÏ ÓÌÉÛËÏÍ ÍÎÏÇÏ ÐÁÒÁÍÅÔÒÏ×"
+
+#: ../roundup/admin.py:866
+#, python-format
+msgid "%(nodeid)4s: %(value)s"
+msgstr ""
+
+#: ../roundup/admin.py:870
+msgid ""
+"Usage: table classname [property[,property]*]\n"
+"        List the instances of a class in tabular form.\n"
+"\n"
+"        Lists all instances of the given class. If the properties are not\n"
+"        specified, all properties are displayed. By default, the column\n"
+"        widths are the width of the largest value. The width may be\n"
+"        explicitly defined by defining the property as \"name:width\".\n"
+"        For example::\n"
+"\n"
+"          roundup> table priority id,name:10\n"
+"          Id Name\n"
+"          1  fatal-bug\n"
+"          2  bug\n"
+"          3  usability\n"
+"          4  feature\n"
+"\n"
+"        Also to make the width of the column the width of the label,\n"
+"        leave a trailing : without a width on the property. For example::\n"
+"\n"
+"          roundup> table priority id,name:\n"
+"          Id Name\n"
+"          1  fata\n"
+"          2  bug\n"
+"          3  usab\n"
+"          4  feat\n"
+"\n"
+"        will result in a the 4 character wide \"Name\" column.\n"
+"        "
+msgstr ""
+"÷ÙÚÏ×: table ËÌÁÓÓ [ÁÔÒÉÂÕÔ[,ÁÔÒÉÂÕÔ]*]\n"
+"        ðÏÌÕÞÉÔØ ÓÐÉÓÏË ÏÂßÅËÔÏ× ËÌÁÓÓÁ × ×ÉÄÅ ÔÁÂÌÉÃÙ.\n"
+"\n"
+"        ÷ÙÄÁÅÔ ÓÐÉÓÏË ×ÓÅÈ ÏÂßÅËÔÏ× ÕËÁÚÁÎÎÏÇÏ ËÌÁÓÓÁ.  åÓÌÉ ÓÐÉÓÏË\n"
+"        ÁÔÒÉÂÕÔÏ× ÎÅ ÕËÁÚÁÎ, ÐÅÞÁÔÁÅÔ ×ÓÅ ÁÔÒÉÂÕÔÙ.  ðÏ ÕÍÏÌÞÁÎÉÀ,\n"
+"        ÓÔÏÌÂÃÙ ÉÍÅÀÔ ÛÉÒÉÎÕ ÓÁÍÏÇÏ ÄÌÉÎÎÏÇÏ ÚÎÁÞÅÎÉÑ × ÜÔÏÍ ÓÔÏÌÂÃÅ.\n"
+"        íÏÖÎÏ Ñ×ÎÏ ÕËÁÚÙ×ÁÔØ ÛÉÒÉÎÙ ÓÔÏÌÂÃÏ× × ×ÉÄÅ \"ÁÔÒÉÂÕÔ:ÛÉÒÉÎÁ\".\n"
+"        îÁÐÒÉÍÅÒ::\n"
+"\n"
+"          roundup> table priority id,name:10\n"
+"          Id Name\n"
+"          1  fatal-bug\n"
+"          2  bug\n"
+"          3  usability\n"
+"          4  feature\n"
+"\n"
+"        äÌÑ ÔÏÇÏ, ÞÔÏÂÙ ÓÔÏÌÂÅà ÉÍÅÌ ÛÉÒÉÎÕ ÚÁÇÏÌÏ×ËÁ, ÎÕÖÎÏ Ë ÉÍÅÎÉ\n"
+"        ÁÔÒÉÂÕÔÁ ÄÏÂÁ×ÉÔØ Ä×ÏÅÔÏÞÉÅ, ÎÏ ÎÅ ÕËÁÚÙ×ÁÔØ ÛÉÒÉÎÕ.  îÁÐÒÉÍÅÒ::\n"
+"\n"
+"          roundup> table priority id,name:\n"
+"          Id Name\n"
+"          1  fata\n"
+"          2  bug\n"
+"          3  usab\n"
+"          4  feat\n"
+"\n"
+"        ÏÂÒÅÚÁÅÔ ÚÎÁÞÅÎÉÑ ÓÔÏÌÂÃÁ \"Name\" ÄÏ ÞÅÔÙÒÅÈ ÓÉÍ×ÏÌÏ×.\n"
+"        "
+
+#: ../roundup/admin.py:914
+#, python-format
+msgid "\"%(spec)s\" not name:width"
+msgstr "úÎÁÞÅÎÉÅ \"%(spec)s\" ÄÏÌÖÎÏ ÂÙÔØ ÚÁÄÁÎÏ ËÁË ÉÍÑ:ÛÉÒÉÎÁ"
+
+#: ../roundup/admin.py:964
+msgid ""
+"Usage: history designator\n"
+"        Show the history entries of a designator.\n"
+"\n"
+"        Lists the journal entries for the node identified by the "
+"designator.\n"
+"        "
+msgstr ""
+"÷ÙÚÏ×: history ÏÐÉÓÁÔÅÌØ\n"
+"        ðÏËÁÚÁÔØ ÉÓÔÏÒÉÀ ÏÂßÅËÔÁ.\n"
+"\n"
+"        ÷ÙÄÁÅÔ ÓÐÉÓÏË ÐÒÏÔÏËÏÌØÎÙÈ ÓÏÏÂÝÅÎÉÊ ÄÌÑ ÏÂßÅËÔÁ,\n"
+"        ÚÁÄÁÎÎÏÇÏ ÏÐÉÓÁÔÅÌÅÍ.\n"
+"        "
+
+#: ../roundup/admin.py:985
+msgid ""
+"Usage: commit\n"
+"        Commit changes made to the database during an interactive session.\n"
+"\n"
+"        The changes made during an interactive session are not\n"
+"        automatically written to the database - they must be committed\n"
+"        using this command.\n"
+"\n"
+"        One-off commands on the command-line are automatically committed if\n"
+"        they are successful.\n"
+"        "
+msgstr ""
+"÷ÙÚÏ×: commit\n"
+"        óÏÈÒÁÎÉÔØ ÉÚÍÅÎÅÎÉÑ ÂÁÚÙ ÄÁÎÎÙÈ, ÓÄÅÌÁÎÎÙÅ × ÉÎÔÅÒÁËÔÉ×ÎÏÍ ÒÅÖÉÍÅ.\n"
+"\n"
+"        éÚÍÅÎÅÎÉÑ, ×ÎÅÓÅÎÎÙÅ × ÉÎÔÅÒÁËÔÉ×ÎÏÍ ÒÅÖÉÍÅ, ÎÅ ÚÁÐÉÓÙ×ÁÀÔÓÑ × ÂÁÚÕ\n"
+"        ÄÁÎÎÙÈ Á×ÔÏÍÁÔÉÞÅÓËÉ.  ïÎÉ ÄÏÌÖÎÙ ÂÙÔØ ÐÒÉÎÕÄÉÔÅÌØÎÏ ÓÏÈÒÁÎÅÎÙ\n"
+"        ÐÒÉ ÐÏÍÏÝÉ ÜÔÏÊ ËÏÍÁÎÄÙ.\n"
+"\n"
+"        òÅÚÕÌØÔÁÔÙ ×ÙÐÏÌÎÅÎÉÑ ËÏÍÁÎÄÎÏÊ ÓÔÒÏËÉ ÚÁÐÉÓÙ×ÁÀÔÓÑ × ÂÁÚÕ ÄÁÎÎÙÈ\n"
+"        Á×ÔÏÍÁÔÉÞÅÓËÉ, ÅÓÌÉ ÐÒÉ ×ÙÐÏÌÎÅÎÉÉ ËÏÍÁÎÄÙ ÎÅ ÐÒÏÉÚÏÛÌÏ ÏÛÉÂËÉ.\n"
+"        "
+
+#: ../roundup/admin.py:999
+msgid ""
+"Usage: rollback\n"
+"        Undo all changes that are pending commit to the database.\n"
+"\n"
+"        The changes made during an interactive session are not\n"
+"        automatically written to the database - they must be committed\n"
+"        manually. This command undoes all those changes, so a commit\n"
+"        immediately after would make no changes to the database.\n"
+"        "
+msgstr ""
+"÷ÙÚÏ×: rollback\n"
+"        ïÔÍÅÎÉÔØ ÉÚÍÅÎÅÎÉÑ ÂÁÚÙ ÄÁÎÎÙÈ, ÓÄÅÌÁÎÎÙÅ × ÉÎÔÅÒÁËÔÉ×ÎÏÍ ÒÅÖÉÍÅ.\n"
+"\n"
+"        éÚÍÅÎÅÎÉÑ, ×ÎÅÓÅÎÎÙÅ × ÉÎÔÅÒÁËÔÉ×ÎÏÍ ÒÅÖÉÍÅ, ÎÅ ÚÁÐÉÓÙ×ÁÀÔÓÑ × ÂÁÚÕ\n"
+"        ÄÁÎÎÙÈ Á×ÔÏÍÁÔÉÞÅÓËÉ.  ïÎÉ ÄÏÌÖÎÙ ÂÙÔØ ÐÒÉÎÕÄÉÔÅÌØÎÏ ÓÏÈÒÁÎÅÎÙ\n"
+"        ÐÒÉ ÐÏÍÏÝÉ ËÏÍÁÎÄÙ commit.  ëÏÍÁÎÄÁ rollback ÏÔÍÅÎÑÅÔ ×ÓÅ ÜÔÉ\n"
+"        ÉÚÍÅÎÅÎÉÑ, ÔÁË ÞÔÏ ÂÁÚÁ ÄÁÎÎÙÈ ×ÏÚ×ÒÁÝÁÅÔÓÑ × ÓÏÓÔÏÑÎÉÅ, ËÏÔÏÒÏÅ\n"
+"        ÂÙÌÏ × ÍÏÍÅÎÔ ÐÏÓÌÅÄÎÅÊ ÚÁÐÉÓÉ.\n"
+"        "
+
+#: ../roundup/admin.py:1011
+msgid ""
+"Usage: retire designator[,designator]*\n"
+"        Retire the node specified by designator.\n"
+"\n"
+"        This action indicates that a particular node is not to be retrieved\n"
+"        by the list or find commands, and its key value may be re-used.\n"
+"        "
+msgstr ""
+"÷ÙÚÏ×: retire ÏÐÉÓÁÔÅÌØ[,ÏÐÉÓÁÔÅÌØ]*\n"
+"        õÄÁÌÉÔØ ÕËÁÚÁÎÎÙÅ ÏÂßÅËÔÙ.\n"
+"\n"
+"        üÔÁ ËÏÍÁÎÄÁ ÐÏÍÅÞÁÅÔ ÕËÁÚÁÎÎÙÅ ÏÂßÅËÔÙ ËÁË ÕÄÁÌÅÎÎÙÅ.\n"
+"        õÄÁÌÅÎÎÙÅ ÏÂßÅËÔÙ ÎÅ ÐÏËÁÚÙ×ÁÀÔÓÑ × ÓÐÉÓËÁÈ, ×ÙÄÁ×ÁÅÍÙÈ\n"
+"        ËÏÍÁÎÄÁÍÉ list É find, É ÉÈ ËÌÀÞÅ×ÙÅ ÚÎÁÞÅÎÉÑ ÍÏÇÕÔ ÂÙÔØ\n"
+"        ÉÓÐÏÌØÚÏ×ÁÎÙ × ÄÒÕÇÉÈ ÏÂßÅËÔÁÈ.\n"
+"        "
+
+#: ../roundup/admin.py:1034
+msgid ""
+"Usage: restore designator[,designator]*\n"
+"        Restore the retired node specified by designator.\n"
+"\n"
+"        The given nodes will become available for users again.\n"
+"        "
+msgstr ""
+"÷ÙÚÏ×: restore ÏÐÉÓÁÔÅÌØ[,ÏÐÉÓÁÔÅÌØ]*\n"
+"        ÷ÏÓÓÔÁÎÏ×ÉÔØ ÕÄÁÌÅÎÎÙÅ ÏÂßÅËÔÙ.\n"
+"\n"
+"        ó ÚÁÄÁÎÎÙÈ ÏÂßÅËÔÏ× ÓÎÉÍÁÅÔÓÑ ÐÏÍÅÔËÁ ÕÄÁÌÅÎÉÑ, É ÜÔÉÍÉ ÏÂßÅËÔÁÍÉ\n"
+"        ÍÏÖÎÏ ÐÏÌØÚÏ×ÁÔØÓÑ ÓÎÏ×Á.\n"
+"        "
+
+#. grab the directory to export to
+#: ../roundup/admin.py:1056
+msgid ""
+"Usage: export [class[,class]] export_dir\n"
+"        Export the database to colon-separated-value files.\n"
+"\n"
+"        Optionally limit the export to just the names classes.\n"
+"\n"
+"        This action exports the current data from the database into\n"
+"        colon-separated-value files that are placed in the nominated\n"
+"        destination directory.\n"
+"        "
+msgstr ""
+"÷ÙÚÏ×: export [ËÌÁÓÓ[,ËÌÁÓÓ]] ËÁÔÁÌÏÇ\n"
+"        üËÓÐÏÒÔÉÒÏ×ÁÔØ ÂÁÚÕ ÄÁÎÎÙÈ × ÔÅËÓÔÏ×ÙÅ ÆÁÊÌÙ.\n"
+"\n"
+"        åÓÌÉ ÓÐÉÓÏË ËÌÁÓÓÏ× ÎÅ ÚÁÄÁÎ, ÜËÓÐÏÒÔÉÒÕÀÔÓÑ ×ÓÅ ËÌÁÓÓÙ\n"
+"        ÂÁÚÙ ÄÁÎÎÙÈ.\n"
+"\n"
+"        üÔÁ ËÏÍÁÎÄÁ ÜËÓÐÏÒÔÉÒÕÅÔ ÄÁÎÎÙÅ ÉÚ ÂÁÚÙ ÔÒÅËÅÒÁ × ÔÅËÓÔÏ×ÙÅ ÆÁÊÌÙ\n"
+"        × ÕËÁÚÁÎÎÏÍ ËÁÔÁÌÏÇÅ.  äÌÑ ËÁÖÄÏÇÏ ÜËÓÐÏÒÔÉÒÕÅÍÏÇÏ ËÌÁÓÓÁ ÓÏÚÄÁÅÔÓÑ\n"
+"        ÏÔÄÅÌØÎÙÊ ÜËÓÐÏÒÔÎÙÊ ÆÁÊÌ.  äÌÑ ËÁÖÄÏÇÏ ÏÂßÅËÔÁ ËÌÁÓÓÁ ÓÏÚÄÁÅÔÓÑ\n"
+"        ÓÔÒÏËÁ ÜËÓÐÏÒÔÎÏÇÏ ÆÁÊÌÁ.  úÎÁÞÅÎÉÑ ÁÔÒÉÂÕÔÏ× ÒÁÚÄÅÌÑÀÔÓÑ\n"
+"        Ä×ÏÅÔÏÞÉÑÍÉ.\n"
+"        "
+
+#: ../roundup/admin.py:1114
+msgid ""
+"Usage: import import_dir\n"
+"        Import a database from the directory containing CSV files,\n"
+"        two per class to import.\n"
+"\n"
+"        The files used in the import are:\n"
+"\n"
+"        <class>.csv\n"
+"          This must define the same properties as the class (including\n"
+"          having a \"header\" line with those property names.)\n"
+"        <class>-journals.csv\n"
+"          This defines the journals for the items being imported.\n"
+"\n"
+"        The imported nodes will have the same nodeid as defined in the\n"
+"        import file, thus replacing any existing content.\n"
+"\n"
+"        The new nodes are added to the existing database - if you want to\n"
+"        create a new database using the imported data, then create a new\n"
+"        database (or, tediously, retire all the old data.)\n"
+"        "
+msgstr ""
+"÷ÙÚÏ×: import ËÁÔÁÌÏÇ\n"
+"        éÍÐÏÒÔÉÒÏ×ÁÔØ ÂÁÚÕ ÄÁÎÎÙÈ ÉÚ ËÁÔÁÌÏÇÁ, ÓÏÄÅÒÖÁÝÅÇÏ ÆÁÊÌÙ\n"
+"        × ÆÏÒÍÁÔÅ CSV (Comma Separated Values), ÐÏ Ä×Á ÆÁÊÌÁ ÎÁ ËÌÁÓÓ.\n"
+"\n"
+"        ÷ ÉÍÐÏÒÔÅ ÕÞÁÓÔ×ÕÀÔ ÓÌÅÄÕÀÝÉÅ ÆÁÊÌÙ:\n"
+"\n"
+"        <ËÌÁÓÓ>.csv\n"
+"          üÔÏÔ ÆÁÊÌ ÄÏÌÖÅÎ ÓÏÄÅÒÖÁÔØ ÄÁÎÎÙÅ ËÌÁÓÓÁ (Ó ÕËÁÚÁÎÉÅÍ ÉÍÅÎ\n"
+"          ÁÔÒÉÂÕÔÏ× × ÐÅÒ×ÏÊ ÓÔÒÏËÅ.)\n"
+"        <ËÌÁÓÓ>-journals.csv\n"
+"          óÏÄÅÒÖÉÔ ÐÒÏÔÏËÏÌÙ ÉÚÍÅÎÅÎÉÊ ÏÂßÅËÔÏ× ËÌÁÓÓÁ.\n"
+"\n"
+"        éÍÐÏÒÔÉÒÕÅÍÙÅ ÏÂßÅËÔÙ ÉÍÅÀÔ ÉÄÅÎÔÉÆÉËÁÔÏÒÙ, ÕËÁÚÁÎÎÙÅ ×\n"
+"        ÉÍÐÏÒÔÎÏÍ ÆÁÊÌÅ É ÚÁÍÅÝÁÀÔ ÏÂßÅËÔÙ Ó ÔÁËÉÍÉ ÖÅ ÉÄÅÎÔÉÆÉËÁÔÏÒÁÍÉ,\n"
+"        ÓÕÝÅÓÔ×ÕÀÝÉÅ × ÂÁÚÅ ÄÁÎÎÙÈ.\n"
+"\n"
+"        îÏ×ÙÅ ÏÂßÅËÔÙ ÄÏÂÁ×ÌÑÀÔÓÑ Ë ÓÕÝÅÓÔ×ÕÀÝÅÊ ÂÁÚÅ ÄÁÎÎÙÈ.\n"
+"        åÓÌÉ ×Ù ÈÏÔÉÔÅ ÚÁÐÏÌÎÉÔØ ÂÁÚÕ ÔÏÌØËÏ ÉÍÐÏÒÔÉÒÕÅÍÙÍÉ ÏÂßÅËÔÁÍÉ,\n"
+"        ×ÁÍ ÎÕÖÎÏ ÓÏÚÄÁÔØ ÎÏ×ÕÀ ÂÁÚÕ ÄÁÎÎÙÈ (ÉÌÉ, ÅÓÌÉ ÎÅ ÌÅÎØ, ÕÄÁÌÉÔØ\n"
+"        ÉÚ ÓÕÝÅÓÔ×ÕÀÝÅÊ ÂÁÚÙ ×ÓÅ ÏÂßÅËÔÙ).\n"
+"        "
+
+#: ../roundup/admin.py:1186
+msgid ""
+"Usage: pack period | date\n"
+"\n"
+"        Remove journal entries older than a period of time specified or\n"
+"        before a certain date.\n"
+"\n"
+"        A period is specified using the suffixes \"y\", \"m\", and \"d\". "
+"The\n"
+"        suffix \"w\" (for \"week\") means 7 days.\n"
+"\n"
+"              \"3y\" means three years\n"
+"              \"2y 1m\" means two years and one month\n"
+"              \"1m 25d\" means one month and 25 days\n"
+"              \"2w 3d\" means two weeks and three days\n"
+"\n"
+"        Date format is \"YYYY-MM-DD\" eg:\n"
+"            2001-01-01\n"
+"\n"
+"        "
+msgstr ""
+"÷ÙÚÏ×: pack ÐÅÒÉÏÄ | ÄÁÔÁ\n"
+"\n"
+"        õÄÁÌÉÔØ ÐÒÏÔÏËÏÌØÎÙÅ ÓÏÏÂÝÅÎÉÑ, ÈÒÁÎÑÝÉÅÓÑ ÄÏÌØÛÅ ÕËÁÚÁÎÎÏÇÏ\n"
+"        ÐÅÒÉÏÄÁ ÉÌÉ ÓÏÚÄÁÎÎÙÅ ÄÏ ÕËÁÚÁÎÎÏÊ ÄÁÔÙ.\n"
+"\n"
+"        ðÅÒÉÏÄ ÚÁÄÁÅÔÓÑ ÞÉÓÌÁÍÉ, Ë ËÏÔÏÒÙÍ ÄÏÂÁ×ÌÅÎÙ ÂÕË×Ù \"y\"\n"
+"        (year - ÇÏÄ), \"m\" (month - ÍÅÓÑÃ), \"d\" (day - ÄÅÎØ)\n"
+"        ÉÌÉ \"w\" (week - ÎÅÄÅÌÑ, ÓÅÍØ ÄÎÅÊ).\n"
+"\n"
+"              \"3y\" ÏÚÎÁÞÁÅÔ ÔÒÉ ÇÏÄÁ\n"
+"              \"2y 1m\" ÏÚÎÁÞÁÅÔ Ä×Á ÇÏÄÁ É ÏÄÉÎ ÍÅÓÑÃ\n"
+"              \"1m 25d\" ÏÚÎÁÞÁÅÔ ÏÄÉÎ ÍÅÓÑÃ É 25 ÄÎÅÊ\n"
+"              \"2w 3d\" ÏÚÎÁÞÁÅÔ Ä×Å ÎÅÄÅÌÉ É ÔÒÉ ÄÎÑ\n"
+"\n"
+"        äÁÔÁ ÚÁÄÁÅÔÓÑ × ÆÏÒÍÁÔÅ \"YYYY-MM-DD\", ÎÁÐÒÉÍÅÒ:\n"
+"            2001-01-01\n"
+"\n"
+"        "
+
+#: ../roundup/admin.py:1214
+msgid "Invalid format"
+msgstr "îÅÐÒÁ×ÉÌØÎÙÊ ÆÏÒÍÁÔ"
+
+#: ../roundup/admin.py:1224
+msgid ""
+"Usage: reindex [classname|designator]*\n"
+"        Re-generate a tracker's search indexes.\n"
+"\n"
+"        This will re-generate the search indexes for a tracker.\n"
+"        This will typically happen automatically.\n"
+"        "
+msgstr ""
+"÷ÙÚÏ×: reindex [ËÌÁÓÓ|ÏÐÒÅÄÅÌÉÔÅÌØ]*\n"
+"        ðÅÒÅÉÎÄÅËÓÉÒÏ×ÁÔØ ÂÁÚÕ ÄÁÎÎÙÈ.\n"
+"\n"
+"        üÔÁ ËÏÍÁÎÄÁ ÐÅÒÅÓÔÒÁÉ×ÁÅÔ ÉÎÄÅËÓÙ, ÉÓÐÏÌØÚÕÅÍÙÅ ÄÌÑ ÐÏÉÓËÁ × ÂÁÚÅ\n"
+"        ÄÁÎÎÙÈ.  ïÂÙÞÎÏ ÐÏÓÔÒÏÅÎÉÅ ÉÎÄÅËÓÏ× ÐÒÏÉÓÈÏÄÉÔ Á×ÔÏÍÁÔÉÞÅÓËÉ.\n"
+"        "
+
+#: ../roundup/admin.py:1238
+#, python-format
+msgid "no such item \"%(designator)s\""
+msgstr "ÏÂßÅËÔ \"%(designator)s\" ÎÅ ÓÕÝÅÓÔ×ÕÅÔ"
+
+#: ../roundup/admin.py:1248
+msgid ""
+"Usage: security [Role name]\n"
+"        Display the Permissions available to one or all Roles.\n"
+"        "
+msgstr ""
+"÷ÙÚÏ×: security [ÒÏÌØ]\n"
+"        ðÏËÁÚÁÔØ ÐÒÁ×Á, ×ÙÄÁÎÎÙÅ ÕËÁÚÁÎÎÏÊ ÒÏÌÉ ÉÌÉ ×ÓÅÍ ÓÕÝÅÓÔ×ÕÀÝÉÍ\n"
+"        ÒÏÌÑÍ.\n"
+"        "
+
+#: ../roundup/admin.py:1256
+#, python-format
+msgid "No such Role \"%(role)s\""
+msgstr "òÏÌØ \"%(role)s\" ÎÅ ÓÕÝÅÓÔ×ÕÅÔ"
+
+#: ../roundup/admin.py:1262
+#, python-format
+msgid "New Web users get the Roles \"%(role)s\""
+msgstr "îÏ×ÙÅ ÐÏÌØÚÏ×ÁÔÅÌÉ web ÐÏÌÕÞÁÀÔ ÒÏÌÉ \"%(role)s\""
+
+#: ../roundup/admin.py:1264
+#, python-format
+msgid "New Web users get the Role \"%(role)s\""
+msgstr "îÏ×ÙÅ ÐÏÌØÚÏ×ÁÔÅÌÉ web ÐÏÌÕÞÁÀÔ ÒÏÌØ \"%(role)s\""
+
+#: ../roundup/admin.py:1267
+#, python-format
+msgid "New Email users get the Roles \"%(role)s\""
+msgstr "îÏ×ÙÅ ÐÏÌØÚÏ×ÁÔÅÌÉ email ÐÏÌÕÞÁÀÔ ÒÏÌÉ \"%(role)s\""
+
+#: ../roundup/admin.py:1269
+#, python-format
+msgid "New Email users get the Role \"%(role)s\""
+msgstr "îÏ×ÙÅ ÐÏÌØÚÏ×ÁÔÅÌÉ email ÐÏÌÕÞÁÀÔ ÒÏÌØ \"%(role)s\""
+
+#: ../roundup/admin.py:1272
+#, python-format
+msgid "Role \"%(name)s\":"
+msgstr "òÏÌØ \"%(name)s\":"
+
+#: ../roundup/admin.py:1277
+#, python-format
+msgid " %(description)s (%(name)s for \"%(klass)s\": %(properties)s only)"
+msgstr ""
+" %(description)s (%(name)s ÄÌÑ ËÌÁÓÓÁ \"%(klass)s\": ÔÏÌØËÏ Ó×ÏÊÓÔ×Á %"
+"(properties)s)"
+
+#: ../roundup/admin.py:1280
+#, python-format
+msgid " %(description)s (%(name)s for \"%(klass)s\" only)"
+msgstr " %(description)s (%(name)s ÔÏÌØËÏ ÄÌÑ ËÌÁÓÓÁ \"%(klass)s\")"
+
+#: ../roundup/admin.py:1283
+#, python-format
+msgid " %(description)s (%(name)s)"
+msgstr ""
+
+#: ../roundup/admin.py:1312
+#, python-format
+msgid "Unknown command \"%(command)s\" (\"help commands\" for a list)"
+msgstr ""
+"ëÏÍÁÎÄÁ \"%(command)s\" ÎÅÉÚ×ÅÓÔÎÁ. (\"help commands\" ×ÙÄÁÅÔ ÓÐÉÓÏË ËÏÍÁÎÄ)"
+
+#: ../roundup/admin.py:1318
+#, python-format
+msgid "Multiple commands match \"%(command)s\": %(list)s"
+msgstr "\"%(command)s\" ÓÏÏÔ×ÅÔÓÔ×ÕÅÔ ÎÅÓËÏÌØËÉÍ ËÏÍÁÎÄÁÍ: %(list)s"
+
+#: ../roundup/admin.py:1325
+msgid "Enter tracker home: "
+msgstr "äÏÍÁÛÎÉÊ ËÁÔÁÌÏÇ ÔÒÅËÅÒÁ: "
+
+#: ../roundup/admin.py:1332 ../roundup/admin.py:1338 ../roundup/admin.py:1358
+#, python-format
+msgid "Error: %(message)s"
+msgstr "ïÛÉÂËÁ: %(message)s"
+
+#: ../roundup/admin.py:1346
+#, python-format
+msgid "Error: Couldn't open tracker: %(message)s"
+msgstr "ïÛÉÂËÁ: ôÒÅËÅÒ ÎÅ ÏÔËÒÙ×ÁÅÔÓÑ: %(message)s"
+
+#: ../roundup/admin.py:1371
+#, python-format
+msgid ""
+"Roundup %s ready for input.\n"
+"Type \"help\" for help."
+msgstr ""
+"Roundup %s Ë ×ÁÛÉÍ ÕÓÌÕÇÁÍ.\n"
+"÷×ÅÄÉÔÅ \"help\" ÄÌÑ ÓÐÒÁ×ËÉ."
+
+#: ../roundup/admin.py:1376
+msgid "Note: command history and editing not available"
+msgstr "ðÒÉÍÅÞÁÎÉÅ: ÒÁÂÏÔÁÅÔ ÒÅÄÁËÔÏÒ É ÉÓÔÏÒÉÑ ËÏÍÁÎÄ"
+
+#: ../roundup/admin.py:1380
+msgid "roundup> "
+msgstr ""
+
+#: ../roundup/admin.py:1382
+msgid "exit..."
+msgstr "ÐÒÉÈÏÄÉÔÅ Ë ÎÁÍ ÅÝÅ..."
+
+#: ../roundup/admin.py:1392
+msgid "There are unsaved changes. Commit them (y/N)? "
+msgstr "ïÊ, ÔÕÔ ÎÅÓÏÈÒÁÎÅÎÎÙÅ ÉÚÍÅÎÅÎÉÑ. úÁÐÉÓÁÔØ × ÂÁÚÕ ÄÁÎÎÙÈ (y/N)? "
+
+#: ../roundup/backends/back_anydbm.py:1997
+#, python-format
+msgid "WARNING: invalid date tuple %r"
+msgstr "÷îéíáîéå! îÅ×ÅÒÎÁÑ ÄÁÔÁ: %r"
+
+#: ../roundup/backends/rdbms_common.py:1434
+msgid "create"
+msgstr "ÓÏÚÄÁÎÉÅ"
+
+#: ../roundup/backends/rdbms_common.py:1600
+msgid "unlink"
+msgstr "ÏÔ×ÑÚËÁ"
+
+#: ../roundup/backends/rdbms_common.py:1604
+msgid "link"
+msgstr "ÐÒÉ×ÑÚËÁ"
+
+#: ../roundup/backends/rdbms_common.py:1724
+msgid "set"
+msgstr "ÕÓÔÁÎÏ×ËÁ"
+
+#: ../roundup/backends/rdbms_common.py:1748
+msgid "retired"
+msgstr "ÚÁÐÒÅÝÅÎÉÅ"
+
+#: ../roundup/backends/rdbms_common.py:1778
+msgid "restored"
+msgstr "×ÏÓÓÔÁÎÏ×ÌÅÎÉÅ"
+
+#: ../roundup/cgi/actions.py:58
+#, python-format
+msgid "You do not have permission to %(action)s the %(classname)s class."
+msgstr "õ ×ÁÓ ÎÅÔ ÒÁÚÒÅÛÅÎÉÑ %(action)s ÄÌÑ ËÌÁÓÓÁ %(classname)s."
+
+#: ../roundup/cgi/actions.py:89
+msgid "No type specified"
+msgstr "îÅ ÕËÁÚÁÎ ÔÉÐ"
+
+#: ../roundup/cgi/actions.py:91
+msgid "No ID entered"
+msgstr "îÅ ÕËÁÚÁÎ ÉÄÅÎÔÉÆÉËÁÔÏÒ"
+
+#: ../roundup/cgi/actions.py:97
+#, python-format
+msgid "\"%(input)s\" is not an ID (%(classname)s ID required)"
+msgstr ""
+"\"%(input)s\" - ÎÅ ÉÄÅÎÔÉÆÉËÁÔÏÒ (ÔÒÅÂÕÅÔÓÑ ÉÄÅÎÔÉÆÉËÁÔÏÒ ËÌÁÓÓÁ %(classname)"
+"s)"
+
+#: ../roundup/cgi/actions.py:117
+msgid "You may not retire the admin or anonymous user"
+msgstr "îÅÌØÚÑ ÕÄÁÌÑÔØ ÐÏÌØÚÏ×ÁÔÅÌÅÊ admin É anonymous."
+
+#: ../roundup/cgi/actions.py:124
+#, python-format
+msgid "%(classname)s %(itemid)s has been retired"
+msgstr "%(classname)s %(itemid)s ÕÄÁÌÅÎ"
+
+#: ../roundup/cgi/actions.py:174 ../roundup/cgi/actions.py:202
+msgid "You do not have permission to edit queries"
+msgstr "õ ×ÁÓ ÎÅÔ ÒÁÚÒÅÛÅÎÉÑ ÎÁ ÒÅÄÁËÔÉÒÏ×ÁÎÉÅ ÚÁÐÒÏÓÏ×"
+
+#: ../roundup/cgi/actions.py:180 ../roundup/cgi/actions.py:209
+msgid "You do not have permission to store queries"
+msgstr "õ ×ÁÓ ÎÅÔ ÒÁÚÒÅÛÅÎÉÑ ÎÁ ÓÏÈÒÁÎÅÎÉÅ ÚÁÐÒÏÓÏ×"
+
+#: ../roundup/cgi/actions.py:297
+#, python-format
+msgid "Not enough values on line %(line)s"
+msgstr "÷ ÓÔÒÏËÅ %(line)s ÎÅ È×ÁÔÁÅÔ ÚÎÁÞÅÎÉÊ"
+
+#: ../roundup/cgi/actions.py:344
+msgid "Items edited OK"
+msgstr "ïÂßÅËÔÙ ÉÚÍÅÎÅÎÙ ÕÓÐÅÛÎÏ"
+
+#: ../roundup/cgi/actions.py:404
+#, python-format
+msgid "%(class)s %(id)s %(properties)s edited ok"
+msgstr "éÚÍÅÎÅÎÙ ÁÔÒÉÂÕÔÙ %(properties)s ÏÂßÅËÔÁ %(class)s %(id)s"
+
+#: ../roundup/cgi/actions.py:407
+#, python-format
+msgid "%(class)s %(id)s - nothing changed"
+msgstr "%(class)s %(id)s - ÎÅÔ ÉÚÍÅÎÅÎÉÊ"
+
+#: ../roundup/cgi/actions.py:419
+#, python-format
+msgid "%(class)s %(id)s created"
+msgstr "%(class)s %(id)s ÓÏÚÄÁÎ"
+
+#: ../roundup/cgi/actions.py:451
+#, python-format
+msgid "You do not have permission to edit %(class)s"
+msgstr "õ ×ÁÓ ÎÅÔ ÒÁÚÒÅÛÅÎÉÑ ÒÅÄÁËÔÉÒÏ×ÁÔØ %(class)s"
+
+#: ../roundup/cgi/actions.py:463
+#, python-format
+msgid "You do not have permission to create %(class)s"
+msgstr "õ ×ÁÓ ÎÅÔ ÒÁÚÒÅÛÅÎÉÑ ÓÏÚÄÁ×ÁÔØ %(class)s"
+
+#: ../roundup/cgi/actions.py:487
+msgid "You do not have permission to edit user roles"
+msgstr "õ ×ÁÓ ÎÅÔ ÒÁÚÒÅÛÅÎÉÑ ÎÁ ÉÚÍÅÎÅÎÉÅ ÒÏÌÅÊ ÐÏÌØÚÏ×ÁÔÅÌÅÊ"
+
+#: ../roundup/cgi/actions.py:537
+#, python-format
+msgid ""
+"Edit Error: someone else has edited this %s (%s). View <a target=\"new\" "
+"href=\"%s%s\">their changes</a> in a new window."
+msgstr ""
+"ïÛÉÂËÁ ÒÅÄÁËÔÉÒÏ×ÁÎÉÑ: %s (%s) ÉÚÍÅÎÉÌ ÄÒÕÇÏÊ ÐÏÌØÚÏ×ÁÔÅÌØ. <a target=\"new"
+"\" href=\"%s%s\">ðÒÏÓÍÏÔÒÅÔØ ÜÔÉ ÉÚÍÅÎÅÎÉÑ</a> × ÄÒÕÇÏÍ ÏËÎÅ."
+
+#: ../roundup/cgi/actions.py:565
+#, python-format
+msgid "Edit Error: %s"
+msgstr "ïÛÉÂËÁ ÒÅÄÁËÔÉÒÏ×ÁÎÉÑ: %s"
+
+#: ../roundup/cgi/actions.py:596 ../roundup/cgi/actions.py:607
+#: ../roundup/cgi/actions.py:778 ../roundup/cgi/actions.py:797
+#, python-format
+msgid "Error: %s"
+msgstr "ïÛÉÂËÁ: %s"
+
+#: ../roundup/cgi/actions.py:633
+msgid ""
+"Invalid One Time Key!\n"
+"(a Mozilla bug may cause this message to show up erroneously, please check "
+"your email)"
+msgstr ""
+"ëÌÀÞ ÐÏÄÔ×ÅÒÖÄÅÎÉÑ ÎÅÐÒÁ×ÉÌÅÎ!\n"
+"(éÚ-ÚÁ ÏÛÉÂËÉ × ÂÒÁÕÚÅÒÅ Mozilla ÜÔÏ ÓÏÏÂÝÅÎÉÅ ÍÏÖÅÔ ÂÙÔØ ÎÅ×ÅÒÎÙÍ. "
+"ðÒÏ×ÅÒØÔÅ ×ÁÛÕ ÐÏÞÔÕ, ÐÏÖÁÌÕÊÓÔÁ.)"
+
+#: ../roundup/cgi/actions.py:675
+#, python-format
+msgid "Password reset and email sent to %s"
+msgstr "ðÁÒÏÌØ ÓÂÒÏÛÅÎ.  ðÏ ÁÄÒÅÓÕ %s ÏÔÐÒÁ×ÌÅÎÏ ÐÉÓØÍÏ."
+
+#: ../roundup/cgi/actions.py:684
+msgid "Unknown username"
+msgstr "îÅÉÚ×ÅÓÔÎÏÅ ÉÍÑ ÐÏÌØÚÏ×ÁÔÅÌÑ"
+
+#: ../roundup/cgi/actions.py:692
+msgid "Unknown email address"
+msgstr "îÅÉÚ×ÅÓÔÎÙÊ ÁÄÒÅÓ email"
+
+#: ../roundup/cgi/actions.py:697
+msgid "You need to specify a username or address"
+msgstr "÷Ù ÄÏÌÖÎÙ ÕËÁÚÁÔØ ÉÍÑ ÐÏÌØÚÏ×ÁÔÅÌÑ ÉÌÉ ÁÄÒÅÓ email"
+
+#: ../roundup/cgi/actions.py:722
+#, python-format
+msgid "Email sent to %s"
+msgstr "ðÉÓØÍÏ ÏÔÐÒÁ×ÌÅÎÏ ÎÁ %s"
+
+#: ../roundup/cgi/actions.py:741
+msgid "You are now registered, welcome!"
+msgstr "÷Ù ÚÁÒÅÇÉÓÔÒÉÒÏ×ÁÎÙ.  äÏÂÒÏ ÐÏÖÁÌÏ×ÁÔØ!"
+
+#: ../roundup/cgi/actions.py:786
+msgid "It is not permitted to supply roles at registration."
+msgstr "îÅÌØÚÑ ÕËÁÚÙ×ÁÔØ ÒÏÌÉ ÐÒÉ ÒÅÇÉÓÔÒÁÃÉÉ"
+
+#: ../roundup/cgi/actions.py:878
+msgid "You are logged out"
+msgstr "óÅÁÎÓ ÒÁÂÏÔÙ ÚÁ×ÅÒÛÅÎ"
+
+#: ../roundup/cgi/actions.py:895
+msgid "Username required"
+msgstr "îÅ ÕËÁÚÁÎÏ ÉÍÑ ÐÏÌØÚÏ×ÁÔÅÌÑ"
+
+#: ../roundup/cgi/actions.py:930 ../roundup/cgi/actions.py:934
+msgid "Invalid login"
+msgstr "îÅÐÒÁ×ÉÌØÎÙÊ ÐÁÒÏÌØ ÉÌÉ ÉÍÑ ÐÏÌØÚÏ×ÁÔÅÌÑ."
+
+#: ../roundup/cgi/actions.py:940
+msgid "You do not have permission to login"
+msgstr "õ ×ÁÓ ÎÅÔ ÒÁÚÒÅÛÅÎÉÑ ÎÁ ÒÁÂÏÔÕ Ó ÓÉÓÔÅÍÏÊ"
+
+#: ../roundup/cgi/cgitb.py:49
+#, python-format
+msgid ""
+"<h1>Templating Error</h1>\n"
+"<p><b>%(exc_type)s</b>: %(exc_value)s</p>\n"
+"<p class=\"help\">Debugging information follows</p>"
+msgstr ""
+"<h1>ïÛÉÂËÁ ÛÁÂÌÏÎÁ</h1>\n"
+"<p><b>%(exc_type)s</b>: %(exc_value)s</p>\n"
+"<p class=\"help\">éÎÆÏÒÍÁÃÉÑ Ï ÏÛÉÂËÅ:</p>"
+
+#: ../roundup/cgi/cgitb.py:64
+#, python-format
+msgid "<li>\"%(name)s\" (%(info)s)</li>"
+msgstr ""
+
+#: ../roundup/cgi/cgitb.py:67
+#, python-format
+msgid "<li>Looking for \"%(name)s\", current path:<ol>%(path)s</ol></li>"
+msgstr "<li>óÉÍ×ÏÌ \"%(name)s\" ÎÅ ÎÁÊÄÅÎ × ÐÕÔÉ:<ol>%(path)s</ol><li>"
+
+#: ../roundup/cgi/cgitb.py:71
+#, python-format
+msgid "<li>In %s</li>"
+msgstr "<li>÷ %s</li>"
+
+#: ../roundup/cgi/cgitb.py:76
+#, python-format
+msgid "A problem occurred in your template \"%s\"."
+msgstr "ïÛÉÂËÁ ÐÒÏÉÚÏÛÌÁ ÐÒÉ ÏÂÒÁÂÏÔËÅ ÛÁÂÌÏÎÁ \"%s\"."
+
+#: ../roundup/cgi/cgitb.py:84
+#, python-format
+msgid ""
+"\n"
+"<li>While evaluating the %(info)r expression on line %(line)d\n"
+"<table class=\"otherinfo\" style=\"font-size: 90%%\">\n"
+" <tr><th colspan=\"2\" class=\"header\">Current variables:</th></tr>\n"
+" %(globals)s\n"
+" %(locals)s\n"
+"</table></li>\n"
+msgstr ""
+"\n"
+"<li>ðÒÉ ×ÙÞÉÓÌÅÎÉÉ ×ÙÒÁÖÅÎÉÑ %(info)r × ÓÔÒÏËÅ %(line)d\n"
+"<table class=\"otherinfo\" style=\"font-size: 90%%\">\n"
+" <tr><th colspan=\"2\" class=\"header\">ïÐÒÅÄÅÌÅÎÙ ÓÌÅÄÕÀÝÉÅ ÐÅÒÅÍÅÎÎÙÅ:</"
+"th></tr>\n"
+" %(globals)s\n"
+" %(locals)s\n"
+"</table></li>\n"
+
+#: ../roundup/cgi/cgitb.py:103
+msgid "Full traceback:"
+msgstr "óÔÅË ×ÙÚÏ×Ï×:"
+
+#: ../roundup/cgi/cgitb.py:116
+#, python-format
+msgid "<font size=+1><strong>%(exc_type)s</strong>: %(exc_value)s</font>"
+msgstr ""
+
+#: ../roundup/cgi/cgitb.py:120
+msgid ""
+"<p>A problem occurred while running a Python script. Here is the sequence of "
+"function calls leading up to the error, with the most recent (innermost) "
+"call first. The exception attributes are:"
+msgstr ""
+"<p>ðÒÉ ×ÙÐÏÌÎÅÎÉÉ ÐÒÏÇÒÁÍÍÙ ÐÒÏÉÚÏÛÌÁ ÏÛÉÂËÁ.  îÉÖÅ ÐÒÉ×ÅÄÅÎÁ "
+"ÐÏÓÌÅÄÏ×ÁÔÅÌØÎÏÓÔØ ×ÙÚÏ×Ï× ÆÕÎËÃÉÊ, ËÏÔÏÒÁÑ ÐÒÉ×ÅÌÁ Ë ÏÛÉÂËÅ.  æÕÎËÃÉÑ, × "
+"ËÏÔÏÒÏÊ ÐÒÏÉÚÏÛÌÁ ÏÛÉÂËÁ, - ÐÏÓÌÅÄÎÑÑ ×ÙÚ×ÁÎÎÁÑ ÆÕÎËÃÉÑ - ÐÏËÁÚÁÎÁ ÐÅÒ×ÏÊ.  "
+"éÎÆÏÒÍÁÃÉÑ Ï ÏÛÉÂËÅ:"
+
+#: ../roundup/cgi/cgitb.py:129
+msgid "&lt;file is None - probably inside <tt>eval</tt> or <tt>exec</tt>&gt;"
+msgstr ""
+"&lt;ÉÍÑ ÆÁÊÌÁ ÎÅ ÏÐÒÅÄÅÌÅÎÏ - ×ÅÒÏÑÔÎÏ ×ÙÚ×ÁÎÏ ÉÚ <tt>eval</tt> ÉÌÉ "
+"<tt>exec</tt>&gt;"
+
+#: ../roundup/cgi/cgitb.py:138
+#, python-format
+msgid "in <strong>%s</strong>"
+msgstr "× <strong>%s</strong>"
+
+#: ../roundup/cgi/cgitb.py:172 ../roundup/cgi/cgitb.py:178
+msgid "<em>undefined</em>"
+msgstr "<em>ÎÅÏÐÒÅÄÅÌÅÎÏ</em>"
+
+#: ../roundup/cgi/client.py:49
+msgid ""
+"<html><head><title>An error has occurred</title></head>\n"
+"<body><h1>An error has occurred</h1>\n"
+"<p>A problem was encountered processing your request.\n"
+"The tracker maintainers have been notified of the problem.</p>\n"
+"</body></html>"
+msgstr ""
+"<html><head><title>ïÛÉÂËÁ × ÔÒÅËÅÒÅ</title></head>\n"
+"<body><h1>ïÛÉÂËÁ × ÔÒÅËÅÒÅ</h1>\n"
+"<p>ðÒÉ ÏÂÒÁÂÏÔËÅ ÷ÁÛÅÇÏ ÚÁÐÒÏÓÁ ÐÒÏÉÚÏÛÌÁ ÏÛÉÂËÁ.\n"
+"áÄÍÉÎÉÓÔÒÁÔÏÒÕ ÔÒÅËÅÒÁ ÏÔÏÓÌÁÎÏ ÓÏÏÂÝÅÎÉÅ Ï ÏÛÉÂËÅ.</p>\n"
+"</body></html>"
+
+#: ../roundup/cgi/client.py:308
+msgid "Form Error: "
+msgstr "ïÛÉÂËÁ ÆÏÒÍÙ: "
+
+#: ../roundup/cgi/client.py:363
+#, python-format
+msgid "Unrecognized charset: %r"
+msgstr "ëÏÄÉÒÏ×ËÁ %r ÎÅ ÒÁÓÐÏÚÎÁÎÁ"
+
+#: ../roundup/cgi/client.py:490
+msgid "Anonymous users are not allowed to use the web interface"
+msgstr "áÎÏÎÉÍÎÙÍ ÐÏÌØÚÏ×ÁÔÅÌÑÍ ÎÅ ÒÁÚÒÅÛÅÎÏ ÐÏÌØÚÏ×ÁÔØÓÑ ×ÅÂ-ÉÎÔÅÒÆÅÊÓÏÍ."
+
+#: ../roundup/cgi/client.py:645
+msgid "You are not allowed to view this file."
+msgstr "õ ×ÁÓ ÎÅÔ ÒÁÚÒÅÛÅÎÉÑ ÎÁ ÐÒÏÓÍÏÔÒ ÜÔÏÇÏ ÆÁÊÌÁ."
+
+#: ../roundup/cgi/client.py:737
+#, python-format
+msgid "%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n"
+msgstr "%(starttag)súÁÔÒÁÞÅÎÎÏÅ ×ÒÅÍÑ: %(seconds)fs%(endtag)s\n"
+
+#: ../roundup/cgi/client.py:741
+#, python-format
+msgid ""
+"%(starttag)sCache hits: %(cache_hits)d, misses %(cache_misses)d. Loading "
+"items: %(get_items)f secs. Filtering: %(filtering)f secs.%(endtag)s\n"
+msgstr ""
+"%(starttag)sëÅÛÉÒÏ×ÁÎÎÙÅ ÜÌÅÍÅÎÔÙ: %(cache_hits)d, ×ÙÞÉÓÌÅÎÎÙÅ: %"
+"(cache_misses)d. úÁÇÒÕÚËÁ ÏÂßÅËÔÏ×: %(get_items)f ÓÅË. æÉÌØÔÒÁÃÉÑ: %"
+"(filtering)f ÓÅË.%(endtag)s\n"
+
+#: ../roundup/cgi/form_parser.py:283
+#, python-format
+msgid "link \"%(key)s\" value \"%(value)s\" not a designator"
+msgstr "úÎÁÞÅÎÉÅ \"%(value)s ÓÓÙÌËÉ \"%(key)s\" ÎÅ ÕËÁÚÙ×ÁÅÔ ÎÁ ÏÂßÅËÔ"
+
+#: ../roundup/cgi/form_parser.py:290
+#, python-format
+msgid "%(class)s %(property)s is not a link or multilink property"
+msgstr "áÔÒÉÂÕÔ %(property)s ËÌÁÓÓÁ %(class)s ÎÅ Ñ×ÌÑÅÔÓÑ ÓÓÙÌÏÞÎÙÍ"
+
+#: ../roundup/cgi/form_parser.py:312
+#, python-format
+msgid ""
+"You have submitted a %(action)s action for the property \"%(property)s\" "
+"which doesn't exist"
+msgstr ""
+"÷Ù ÚÁÐÒÏÓÉÌÉ ÄÅÊÓÔ×ÉÅ \"%(action)s\" ÄÌÑ ÁÔÒÉÂÕÔÁ \"%(property)s\", ËÏÔÏÒÙÊ "
+"ÎÅ ÓÕÝÅÓÔ×ÕÅÔ"
+
+#: ../roundup/cgi/form_parser.py:331 ../roundup/cgi/form_parser.py:357
+#, python-format
+msgid "You have submitted more than one value for the %s property"
+msgstr "÷Ù ××ÅÌÉ ÎÅÓËÏÌØËÏ ÚÎÁÞÅÎÉÊ ÄÌÑ ÁÔÒÉÂÕÔÁ %s"
+
+# ../roundup/cgi/form_parser.py:354 :360
+#: ../roundup/cgi/form_parser.py:354 ../roundup/cgi/form_parser.py:360
+msgid "Password and confirmation text do not match"
+msgstr "ðÁÒÏÌÉ ÎÅ ÓÏ×ÐÁÌÉ"
+
+#: ../roundup/cgi/form_parser.py:395
+#, python-format
+msgid "property \"%(propname)s\": \"%(value)s\" not currently in list"
+msgstr "ÁÔÒÉÂÕÔ \"%(propname)s\": ÚÎÁÞÅÎÉÅ \"%(value)s\" ÏÔÓÕÔÓÔ×ÕÅÔ × ÓÐÉÓËÅ"
+
+#: ../roundup/cgi/form_parser.py:512
+#, python-format
+msgid "Required %(class)s property %(property)s not supplied"
+msgid_plural "Required %(class)s properties %(property)s not supplied"
+msgstr[0] "ïÂÑÚÁÔÅÌØÎÙÊ ÁÔÒÉÂÕÔ %(property)s ËÌÁÓÓÁ %(class)s ÎÅ ÚÁÐÏÌÎÅÎ"
+msgstr[1] "ïÂÑÚÁÔÅÌØÎÙÅ ÁÔÒÉÂÕÔÙ %(property)s ËÌÁÓÓÁ %(class)s ÎÅ ÚÁÐÏÌÎÅÎÙ"
+msgstr[2] "ïÂÑÚÁÔÅÌØÎÙÅ ÁÔÒÉÂÕÔÙ %(property)s ËÌÁÓÓÁ %(class)s ÎÅ ÚÁÐÏÌÎÅÎÙ"
+
+#: ../roundup/cgi/form_parser.py:535
+msgid "File is empty"
+msgstr "æÁÊÌ ÐÕÓÔ"
+
+#: ../roundup/cgi/templating.py:72
+#, python-format
+msgid "You are not allowed to %(action)s items of class %(class)s"
+msgstr "õ ×ÁÓ ÎÅÔ ÒÁÚÒÅÛÅÎÉÑ %(action)s ÄÌÑ ËÌÁÓÓÁ %(class)s"
+
+#: ../roundup/cgi/templating.py:627
+msgid "(list)"
+msgstr "(ÓÐÉÓÏË)"
+
+#: ../roundup/cgi/templating.py:696
+msgid "Submit New Entry"
+msgstr "äÏÂÁ×ÉÔØ"
+
+# ../roundup/cgi/templating.py:673 :792 :1166 :1187 :1231 :1253 :1287 :1326
+# :1377 :1394 :1470 :1490 :1503 :1520 :1530 :1580 :1755
+#: ../roundup/cgi/templating.py:710 ../roundup/cgi/templating.py:829
+#: ../roundup/cgi/templating.py:1236 ../roundup/cgi/templating.py:1257
+#: ../roundup/cgi/templating.py:1304 ../roundup/cgi/templating.py:1327
+#: ../roundup/cgi/templating.py:1361 ../roundup/cgi/templating.py:1400
+#: ../roundup/cgi/templating.py:1453 ../roundup/cgi/templating.py:1470
+#: ../roundup/cgi/templating.py:1549 ../roundup/cgi/templating.py:1569
+#: ../roundup/cgi/templating.py:1587 ../roundup/cgi/templating.py:1619
+#: ../roundup/cgi/templating.py:1629 ../roundup/cgi/templating.py:1683
+#: ../roundup/cgi/templating.py:1875
+msgid "[hidden]"
+msgstr "[ÎÅÄÏÓÔÕÐÎÏ]"
+
+#: ../roundup/cgi/templating.py:711
+msgid "New node - no history"
+msgstr "îÏ×ÁÑ ËÁÒÔÏÞËÁ - ÎÅÔ ÉÓÔÏÒÉÉ"
+
+#: ../roundup/cgi/templating.py:811
+msgid "Submit Changes"
+msgstr "éÚÍÅÎÉÔØ"
+
+#: ../roundup/cgi/templating.py:893
+msgid "<em>The indicated property no longer exists</em>"
+msgstr "<em>õËÁÚÁÎÎÙÊ ÁÔÒÉÂÕÔ ÕÖÅ ÎÅ ÓÕÝÅÓÔ×ÕÅÔ.</em>"
+
+#: ../roundup/cgi/templating.py:894
+#, python-format
+msgid "<em>%s: %s</em>\n"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:907
+#, python-format
+msgid "The linked class %(classname)s no longer exists"
+msgstr "ó×ÑÚÑÎÎÙÊ ËÌÁÓÓ %(classname)s ÕÖÅ ÎÅ ÓÕÝÅÓÔ×ÕÅÔ"
+
+# :823
+#: ../roundup/cgi/templating.py:940 ../roundup/cgi/templating.py:964
+msgid "<strike>The linked node no longer exists</strike>"
+msgstr "<strike>ó×ÑÚÁÎÎÙÊ ÏÂßÅËÔ ÕÖÅ ÎÅ ÓÕÝÅÓÔ×ÕÅÔ</strike>"
+
+#: ../roundup/cgi/templating.py:1006 ../roundup/cgi/templating.py:1404
+#: ../roundup/cgi/templating.py:1425 ../roundup/cgi/templating.py:1431
+msgid "No"
+msgstr "îÅÔ"
+
+#: ../roundup/cgi/templating.py:1006 ../roundup/cgi/templating.py:1404
+#: ../roundup/cgi/templating.py:1423 ../roundup/cgi/templating.py:1428
+msgid "Yes"
+msgstr "äÁ"
+
+#: ../roundup/cgi/templating.py:1017
+#, python-format
+msgid "%s: (no value)"
+msgstr "%s: (ÎÅÔ ÚÎÁÞÅÎÉÑ)"
+
+#: ../roundup/cgi/templating.py:1029
+msgid ""
+"<strong><em>This event is not handled by the history display!</em></strong>"
+msgstr "<strong><em>îÅÉÚ×ÅÓÔÎÙÊ ÔÉÐ ÓÏÂÙÔÉÑ!</em></strong>"
+
+#: ../roundup/cgi/templating.py:1041
+msgid "<tr><td colspan=4><strong>Note:</strong></td></tr>"
+msgstr "<tr><td colspan=4><strong>ðÒÉÍÅÞÁÎÉÅ:</strong></td></tr>"
+
+#: ../roundup/cgi/templating.py:1050
+msgid "History"
+msgstr "éÓÔÏÒÉÑ"
+
+#: ../roundup/cgi/templating.py:1052
+msgid "<th>Date</th>"
+msgstr "<th>äÁÔÁ</th>"
+
+#: ../roundup/cgi/templating.py:1053
+msgid "<th>User</th>"
+msgstr "<th>ðÏÌØÚÏ×ÁÔÅÌØ</th>"
+
+#: ../roundup/cgi/templating.py:1054
+msgid "<th>Action</th>"
+msgstr "<th>äÅÊÓÔ×ÉÅ</th>"
+
+#: ../roundup/cgi/templating.py:1055
+msgid "<th>Args</th>"
+msgstr "<th>ðÁÒÁÍÅÔÒÙ</th>"
+
+#: ../roundup/cgi/templating.py:1097
+#, python-format
+msgid "Copy of %(class)s %(id)s"
+msgstr "ëÏÐÉÑ: %(class)s %(id)s"
+
+#: ../roundup/cgi/templating.py:1331
+msgid "*encrypted*"
+msgstr "*ÚÁÛÉÆÒÏ×ÁÎ*"
+
+#: ../roundup/cgi/templating.py:1514
+msgid ""
+"default value for DateHTMLProperty must be either DateHTMLProperty or string "
+"date representation."
+msgstr ""
+"ÚÎÁÞÅÎÉÅ ÐÏ ÕÍÏÌÞÁÎÉÀ ÄÌÑ DateHTMLProperty ÄÏÌÖÎÏ ÂÙÔØ ÏÂßÅËÔÏÍ "
+"DateHTMLProperty ÉÌÉ ÓÔÒÏËÏ×ÙÍ ÐÒÅÄÓÔÁ×ÌÅÎÉÅÍ ÄÁÔÙ."
+
+#: ../roundup/cgi/templating.py:1674
+#, python-format
+msgid "Attempt to look up %(attr)s on a missing value"
+msgstr "ðÏÐÙÔËÁ ÐÏÌÕÞÉÔØ ÁÔÒÉÂÕÔ \"%(attr)s\" ÎÅÓÕÝÅÓÔ×ÕÀÝÅÇÏ ÏÂßÅËÔÁ"
+
+#: ../roundup/cgi/templating.py:1750
+#, python-format
+msgid "<option %svalue=\"-1\">- no selection -</option>"
+msgstr "<option %svalue=\"-1\">- ÎÅ ÕËÁÚÁÎÏ -</option>"
+
+#: ../roundup/date.py:186
+msgid ""
+"Not a date spec: \"yyyy-mm-dd\", \"mm-dd\", \"HH:MM\", \"HH:MM:SS\" or "
+"\"yyyy-mm-dd.HH:MM:SS.SSS\""
+msgstr ""
+"äÁÔÁ ÄÏÌÖÎÁ ÂÙÔØ × ÆÏÒÍÁÔÅ \"yyyy-mm-dd\", \"mm-dd\", \"HH:MM\", \"HH:MM:SS"
+"\" ÉÌÉ \"yyyy-mm-dd.HH:MM:SS.SSS\""
+
+#: ../roundup/date.py:240
+#, python-format
+msgid ""
+"%r not a date / time spec \"yyyy-mm-dd\", \"mm-dd\", \"HH:MM\", \"HH:MM:SS\" "
+"or \"yyyy-mm-dd.HH:MM:SS.SSS\""
+msgstr ""
+"îÅ×ÅÒÎÏÅ ÚÎÁÞÅÎÉÅ ÄÁÔÙ/×ÒÅÍÅÎÉ: %r.  äÁÔÁ ÄÏÌÖÎÁ ÂÙÔØ × ÆÏÒÍÁÔÅ \"yyyy-mm-dd"
+"\", \"mm-dd\", \"HH:MM\", \"HH:MM:SS\" ÉÌÉ \"yyyy-mm-dd.HH:MM:SS.SSS\""
+
+#: ../roundup/date.py:538
+msgid ""
+"Not an interval spec: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS] [date spec]"
+msgstr ""
+"éÎÔÅÒ×ÁÌ ÄÏÌÖÅÎ ÂÙÔØ × ÆÏÒÍÁÔÅ [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS] [ÄÁÔÁ]"
+
+#: ../roundup/date.py:557
+msgid "Not an interval spec: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS]"
+msgstr "éÎÔÅÒ×ÁÌ ÄÏÌÖÅÎ ÂÙÔØ × ÆÏÒÍÁÔÅ [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS]"
+
+#: ../roundup/date.py:694
+#, python-format
+msgid "%(number)s year"
+msgid_plural "%(number)s years"
+msgstr[0] "%(number)s ÇÏÄ"
+msgstr[1] "%(number)s ÇÏÄÁ"
+msgstr[2] "%(number)s ÌÅÔ"
+
+#: ../roundup/date.py:698
+#, python-format
+msgid "%(number)s month"
+msgid_plural "%(number)s months"
+msgstr[0] "%(number)s ÍÅÓÑÃ"
+msgstr[1] "%(number)s ÍÅÓÑÃÁ"
+msgstr[2] "%(number)s ÍÅÓÑÃÅ×"
+
+#: ../roundup/date.py:702
+#, python-format
+msgid "%(number)s week"
+msgid_plural "%(number)s weeks"
+msgstr[0] "%(number)s ÎÅÄÅÌÑ"
+msgstr[1] "%(number)s ÎÅÄÅÌÉ"
+msgstr[2] "%(number)s ÎÅÄÅÌØ"
+
+#: ../roundup/date.py:706
+#, python-format
+msgid "%(number)s day"
+msgid_plural "%(number)s days"
+msgstr[0] "%(number)s ÄÅÎØ"
+msgstr[1] "%(number)s ÄÎÑ"
+msgstr[2] "%(number)s ÄÎÅÊ"
+
+#: ../roundup/date.py:710
+msgid "tomorrow"
+msgstr "ÚÁ×ÔÒÁ"
+
+#: ../roundup/date.py:712
+msgid "yesterday"
+msgstr "×ÞÅÒÁ"
+
+#: ../roundup/date.py:715
+#, python-format
+msgid "%(number)s hour"
+msgid_plural "%(number)s hours"
+msgstr[0] "%(number)s ÞÁÓ"
+msgstr[1] "%(number)s ÞÁÓÁ"
+msgstr[2] "%(number)s ÞÁÓÏ×"
+
+#: ../roundup/date.py:719
+msgid "an hour"
+msgstr "ÞÁÓ"
+
+#: ../roundup/date.py:721
+msgid "1 1/2 hours"
+msgstr "ÐÏÌÔÏÒÁ ÞÁÓÁ"
+
+# third form ain't used
+#: ../roundup/date.py:723
+#, python-format
+msgid "1 %(number)s/4 hours"
+msgid_plural "1 %(number)s/4 hours"
+msgstr[0] "ÞÁÓ Ó ÞÅÔ×ÅÒÔØÀ"
+msgstr[1] "ÞÁÓ É %(number)s ÞÅÔ×ÅÒÔÉ"
+msgstr[2] "ÞÁÓ É %(number)s ÞÅÔ×ÅÒÔÅÊ"
+
+#: ../roundup/date.py:727
+msgid "in a moment"
+msgstr "ÓÅÊÞÁÓ"
+
+#: ../roundup/date.py:729
+msgid "just now"
+msgstr "ÔÏÌØËÏ ÞÔÏ"
+
+# ÉÓÐÏÌØÚÕÅÔÓÑ × ×ÙÒÁÖÅÎÉÑÈ "ÞÅÒÅÚ ÍÉÎÕÔÕ" ÉÌÉ "ÍÉÎÕÔÕ ÎÁÚÁÄ"
+#: ../roundup/date.py:732
+msgid "1 minute"
+msgstr "ÍÉÎÕÔÕ"
+
+# ÉÓÐÏÌØÚÕÅÔÓÑ × ×ÙÒÁÖÅÎÉÑÈ "ÞÅÒÅÚ 2 ÍÉÎÕÔÙ" ÉÌÉ "2 ÍÉÎÕÔÙ ÎÁÚÁÄ"
+#: ../roundup/date.py:735
+#, python-format
+msgid "%(number)s minute"
+msgid_plural "%(number)s minutes"
+msgstr[0] "%(number)s ÍÉÎÕÔÕ"
+msgstr[1] "%(number)s ÍÉÎÕÔÙ"
+msgstr[2] "%(number)s ÍÉÎÕÔ"
+
+#: ../roundup/date.py:738
+msgid "1/2 an hour"
+msgstr "ÐÏÌÞÁÓÁ"
+
+#: ../roundup/date.py:740
+#, python-format
+msgid "%(number)s/4 hour"
+msgid_plural "%(number)s/4 hours"
+msgstr[0] "ÞÅÔ×ÅÒÔØ ÞÁÓÁ"
+msgstr[1] "%(number)s ÞÅÔ×ÅÒÔÉ ÞÁÓÁ"
+msgstr[2] "%(number)s ÞÅÔ×ÅÒÔÅÊ ÞÁÓÁ"
+
+#: ../roundup/date.py:744
+#, python-format
+msgid "%s ago"
+msgstr "%s ÎÁÚÁÄ"
+
+#: ../roundup/date.py:746
+#, python-format
+msgid "in %s"
+msgstr "ÞÅÒÅÚ %s"
+
+#: ../roundup/init.py:134
+#, python-format
+msgid ""
+"WARNING: directory '%s'\n"
+"\tcontains old-style template - ignored"
+msgstr ""
+"÷îéíáîéå! ëÁÔÁÌÏÇ '%s'\n"
+"\tÓÏÄÅÒÖÉÔ ÛÁÂÌÏÎ ÓÔÁÒÏÇÏ ÏÂÒÁÚÃÁ - ÐÒÏÐÕÝÅÎ"
+
+#: ../roundup/roundupdb.py:141
+msgid "files"
+msgstr "ÆÁÊÌÙ"
+
+#: ../roundup/roundupdb.py:141
+msgid "messages"
+msgstr "ÓÏÏÂÝÅÎÉÑ"
+
+#: ../roundup/roundupdb.py:141
+msgid "nosy"
+msgstr "ÉÚ×ÅÝÅÎÉÑ"
+
+#: ../roundup/roundupdb.py:141
+msgid "superseder"
+msgstr "ÚÁÍÅÝÅÎÉÅ"
+
+#: ../roundup/roundupdb.py:141
+msgid "title"
+msgstr "ÚÁÇÌÁ×ÉÅ"
+
+#: ../roundup/roundupdb.py:142
+msgid "assignedto"
+msgstr "ÉÓÐÏÌÎÉÔÅÌØ"
+
+#: ../roundup/roundupdb.py:142
+msgid "priority"
+msgstr "ÐÒÉÏÒÉÔÅÔ"
+
+#: ../roundup/roundupdb.py:142
+msgid "status"
+msgstr "ÓÔÁÔÕÓ"
+
+#: ../roundup/roundupdb.py:142
+msgid "topic"
+msgstr "ÔÅÍÁ"
+
+#: ../roundup/roundupdb.py:145
+msgid "activity"
+msgstr "ÄÅÊÓÔ×ÉÅ"
+
+#. following properties are common for all hyperdb classes
+#. they are listed here to keep things in one place
+#: ../roundup/roundupdb.py:145
+msgid "actor"
+msgstr "×ÙÐÏÌÎÉÌ"
+
+#: ../roundup/roundupdb.py:145
+msgid "creation"
+msgstr "ÄÁÔÁ ÓÏÚÄÁÎÉÑ"
+
+#: ../roundup/roundupdb.py:145
+msgid "creator"
+msgstr "Á×ÔÏÒ"
+
+#: ../roundup/scripts/roundup_demo.py:32
+#, python-format
+msgid "Enter directory path to create demo tracker [%s]: "
+msgstr "÷×ÅÄÉÔÅ ÉÍÑ ËÁÔÁÌÏÇÁ ÄÌÑ ÄÅÍÏÎÓÔÒÁÃÉÏÎÎÏÇÏ ÔÒÅËÅÒÁ [%s]: "
+
+#: ../roundup/scripts/roundup_gettext.py:22
+#, python-format
+msgid "Usage: %(program)s <tracker home>"
+msgstr "÷ÙÚÏ×: %(program)s <ÄÏÍÁÛÎÉÊ ËÁÔÁÌÏÇ ÔÒÅËÅÒÁ>"
+
+#: ../roundup/scripts/roundup_gettext.py:37
+#, python-format
+msgid "No tracker templates found in directory %s"
+msgstr "÷ ËÁÔÁÌÏÇÅ %s ÎÅ ÎÁÊÄÅÎÏ ÎÉ ÏÄÎÏÇÏ ÛÁÂÌÏÎÁ ÔÒÅËÅÒÁ"
+
+#: ../roundup/scripts/roundup_mailgw.py:36
+#, python-format
+msgid ""
+"Usage: %(program)s [-v] [-c] [[-C class] -S field=value]* <instance home> "
+"[method]\n"
+"\n"
+"Options:\n"
+" -v: print version and exit\n"
+" -c: default class of item to create (else the tracker's "
+"MAIL_DEFAULT_CLASS)\n"
+" -C / -S: see below\n"
+"\n"
+"The roundup mail gateway may be called in one of four ways:\n"
+" . with an instance home as the only argument,\n"
+" . with both an instance home and a mail spool file,\n"
+" . with both an instance home and a POP/APOP server account, or\n"
+" . with both an instance home and a IMAP/IMAPS server account.\n"
+"\n"
+"It also supports optional -C and -S arguments that allows you to set a\n"
+"fields for a class created by the roundup-mailgw. The default class if\n"
+"not specified is msg, but the other classes: issue, file, user can\n"
+"also be used. The -S or --set options uses the same\n"
+"property=value[;property=value] notation accepted by the command line\n"
+"roundup command or the commands that can be given on the Subject line\n"
+"of an email message.\n"
+"\n"
+"It can let you set the type of the message on a per email address basis.\n"
+"\n"
+"PIPE:\n"
+" In the first case, the mail gateway reads a single message from the\n"
+" standard input and submits the message to the roundup.mailgw module.\n"
+"\n"
+"UNIX mailbox:\n"
+" In the second case, the gateway reads all messages from the mail spool\n"
+" file and submits each in turn to the roundup.mailgw module. The file is\n"
+" emptied once all messages have been successfully handled. The file is\n"
+" specified as:\n"
+"   mailbox /path/to/mailbox\n"
+"\n"
+"POP:\n"
+" In the third case, the gateway reads all messages from the POP server\n"
+" specified and submits each in turn to the roundup.mailgw module. The\n"
+" server is specified as:\n"
+"    pop username:password at server\n"
+" The username and password may be omitted:\n"
+"    pop username at server\n"
+"    pop server\n"
+" are both valid. The username and/or password will be prompted for if\n"
+" not supplied on the command-line.\n"
+"\n"
+"APOP:\n"
+" Same as POP, but using Authenticated POP:\n"
+"    apop username:password at server\n"
+"\n"
+"IMAP:\n"
+" Connect to an IMAP server. This supports the same notation as that of\n"
+" POP mail.\n"
+"    imap username:password at server\n"
+" It also allows you to specify a specific mailbox other than INBOX using\n"
+" this format:\n"
+"    imap username:password at server mailbox\n"
+"\n"
+"IMAPS:\n"
+" Connect to an IMAP server over ssl.\n"
+" This supports the same notation as IMAP.\n"
+"    imaps username:password at server [mailbox]\n"
+"\n"
+msgstr ""
+"÷ÙÚÏ×: %(program)s [-v] [-c] [[-C ËÌÁÓÓ] -S ÐÏÌÅ=ÚÎÁÞÅÎÉÅ]* <ËÁÔÁÌÏÇ "
+"ÔÒÅËÅÒÁ> [ÐÏÞÔÏ×ÙÊ ÑÝÉË]\n"
+"\n"
+"ëÌÀÞÉ:\n"
+" -v: ÐÏËÁÚÁÔØ ÎÏÍÅÒ ×ÅÒÓÉÉ É ×ÙÊÔÉ\n"
+" -c: ËÌÁÓÓ ÓÏÚÄÁ×ÁÅÍÙÈ ÏÂßÅËÔÏ×, ÅÓÌÉ ÏÎ ÎÅ ÓÏÄÅÒÖÉÔÓÑ × ÐÉÓØÍÅ.\n"
+"     åÓÌÉ ÎÅ ÕËÁÚÁÎ, ÉÓÐÏÌØÚÕÅÔÓÑ ÎÁÓÔÒÏÊËÁ ÔÒÅËÅÒÁ MAIL_DEFAULT_CLASS\n"
+" -C / -S: ÓÍ.ÎÉÖÅ\n"
+"\n"
+"ðÏÞÔÏ×ÙÊ ÛÌÀÚ roundup ÍÏÖÅÔ ÂÙÔØ ×ÙÚ×ÁÎ ÏÄÎÉÍ ÉÚ ÞÅÔÙÒÅÈ ÓÐÏÓÏÂÏ×:\n"
+" . Ó ÅÄÉÎÓÔ×ÅÎÎÙÍ ÁÒÇÕÍÅÎÔÏÍ - \"ÄÏÍÁÛÎÉÍ\" ËÁÔÁÌÏÇÏÍ ÔÒÅËÅÒÁ,\n"
+" . Ó ÕËÁÚÁÎÉÅÍ ËÁÔÁÌÏÇÁ ÔÒÅËÅÒÁ É ÐÏÞÔÏ×ÏÇÏ ÆÁÊÌÁ × ÆÏÒÍÁÔÅ mailbox,\n"
+" . Ó ÕËÁÚÁÎÉÅÍ ËÁÔÁÌÏÇÁ ÔÒÅËÅÒÁ É ÐÏÞÔÏ×ÏÇÏ ÑÝÉËÁ POP ÉÌÉ APOP,\n"
+" . Ó ÕËÁÚÁÎÉÅÍ ËÁÔÁÌÏÇÁ ÔÒÅËÅÒÁ É ÐÏÞÔÏ×ÏÇÏ ÑÝÉËÁ IMAP ÉÌÉ IMAPS.\n"
+"\n"
+"ðÏÞÔÏ×ÙÊ ÛÌÀÚ ÐÏÚ×ÏÌÑÅÔ ÕËÁÚÁÔØ ÔÁËÖÅ ÐÁÒÁÍÅÔÒÙ -C É -S, ÐÒÉ ÐÏÍÏÝÉ\n"
+"ËÏÔÏÒÙÈ ÍÏÖÎÏ ÚÁÐÏÌÎÉÔØ ÄÏÐÏÌÎÉÔÅÌØÎÙÅ ÐÏÌÑ ËÌÁÓÓÁ, ÏÂßÅËÔÙ ËÏÔÏÒÏÇÏ\n"
+"ÓÏÚÄÁÅÔ roundup-mailgw.  åÓÌÉ ËÌÁÓÓ ÎÅ ÕËÁÚÁÎ, ÓÏÚÄÁÀÔÓÑ ÏÂßÅËÔÙ msg,\n"
+"ÎÏ ÍÏÖÎÏ ÉÓÐÏÌØÚÏ×ÁÔØ É ÄÒÕÇÉÅ ËÌÁÓÓÙ: issue, file ÉÌÉ user.\n"
+"ëÌÀÞ -S ÉÌÉ --set ÕÓÔÁÎÁ×ÌÉ×ÁÅÔ ÁÔÒÉÂÕÔÙ ÏÂßÅËÔÁ, ÉÓÐÏÌØÚÕÑ ÔÕ ÖÅ\n"
+"ÎÏÔÁÃÉÀ, ÞÔÏ É ÉÎÔÅÒÆÅÊÓ ËÏÍÁÎÄÎÏÊ ÓÔÒÏËÉ roundup ÉÌÉ ËÏÍÁÎÄÙ,\n"
+"ËÏÔÏÒÙÅ ÍÏÇÕÔ ÂÙÔØ ÐÏÄÁÎÙ × ÓÔÒÏËÅ Subject email-ÓÏÏÂÝÅÎÉÑ:\n"
+"ÁÔÒÉÂÕÔ=ÚÎÁÞÅÎÉÅ[;ÁÔÒÉÂÕÔ=ÚÎÁÞÅÎÉÅ].\n"
+"\n"
+"ðÒÉ ÐÏÍÏÝÉ ÜÔÉÈ ÐÁÒÁÍÅÔÒÏ× ÍÏÖÎÏ ÓÔÒÏÉÔØ ÒÁÚÎÙÅ ÏÂÒÁÂÏÔÞÉËÉ ÓÏÏÂÝÅÎÉÊ\n"
+"ÄÌÑ ÒÁÚÎÙÈ ÁÄÒÅÓÏ× ÐÏÞÔÏ×ÏÇÏ ÛÌÀÚÁ.\n"
+"\n"
+"óÐÏÓÏÂÙ ÐÏÌÕÞÅÎÉÑ email-ÓÏÏÂÝÅÎÉÊ:\n"
+"\n"
+"ëÁÎÁÌ (pipe):\n"
+" åÓÌÉ × ÐÁÒÁÍÅÔÒÁÈ ×ÙÚÏ×Á ÎÅ ÕËÁÚÁÎ ÐÏÞÔÏ×ÙÊ ÑÝÉË, roundup-mailgw\n"
+" ÞÉÔÁÅÔ ÏÄÎÏ ÓÏÏÂÝÅÎÉÅ ÉÚ ÓÔÁÎÄÁÒÔÎÏÇÏ ×ÈÏÄÎÏÇÏ ÐÏÔÏËÁ (stdin).\n"
+" üÔÏ ÓÏÏÂÝÅÎÉÅ ÐÅÒÅÄÁÅÔÓÑ ÄÌÑ ÏÂÒÁÂÏÔËÉ ÍÏÄÕÌÀ roundup.mailgw.\n"
+"\n"
+"Mailbox:\n"
+" ÷ ÜÔÏÍ ÓÌÕÞÁÅ roundup-mailgw ÞÉÔÁÅÔ ×ÓÅ ÓÏÏÂÝÅÎÉÑ ÉÚ ÐÏÞÔÏ×ÏÇÏ ÆÁÊÌÁ,\n"
+" ËÏÔÏÒÙÊ ÄÏÌÖÅÎ ÉÍÅÔØ ÆÏÒÍÁÔ mail spool file.  ëÁÖÄÏÅ ÓÏÏÂÝÅÎÉÅ ÐÅÒÅÄÁÅÔÓÑ\n"
+" ÄÌÑ ÏÂÒÁÂÏÔËÉ ÍÏÄÕÌÀ roundup.mailgw.  ëÏÇÄÁ ×ÓÅ ÓÏÏÂÝÅÎÉÑ ÕÓÐÅÛÎÏ\n"
+" ÏÂÒÁÂÏÔÁÎÙ, ÐÏÞÔÏ×ÙÊ ÆÁÊÌ ÏÞÉÝÁÅÔÓÑ.  éÍÑ ÐÏÞÔÏ×ÏÇÏ ÆÁÊÌÁ ÕËÁÚÙ×ÁÅÔÓÑ ÔÁË:\n"
+"   mailbox /path/to/mailbox\n"
+"\n"
+"POP:\n"
+" ðÒÉ ÔÁËÏÍ ×ÙÚÏ×Å roundup-mailgs ÚÁÂÉÒÁÅÔ ×ÓÅ ÓÏÏÂÝÅÎÉÑ, ÐÏÓÔÕÐÉ×ÛÉÅ\n"
+" × ÐÏÞÔÏ×ÙÊ ÑÝÉË ÎÁ ÓÅÒ×ÅÒÅ POP3.  ëÁÖÄÏÅ ÐÏÌÕÞÅÎÎÏÅ ÓÏÏÂÝÅÎÉÅ ÐÅÒÅÄÁÅÔÓÑ\n"
+" ÄÌÑ ÏÂÒÁÂÏÔËÉ ÍÏÄÕÌÀ roundup.mailgw.  ðÏÞÔÏ×ÙÊ ÑÝÉË ÕËÁÚÙ×ÁÅÔÓÑ × ×ÉÄÅ:\n"
+"    pop username:password at server\n"
+" éÍÑ ÐÏÌØÚÏ×ÁÔÅÌÑ É ÐÁÒÏÌØ ÍÏÇÕÔ ÂÙÔØ ÏÐÕÝÅÎÙ:\n"
+"    pop username at server\n"
+"    pop server\n"
+" åÓÌÉ ÉÍÑ ÉÌÉ ÐÁÒÏÌØ ÎÅ ÕËÁÚÁÎÙ, ÐÒÏÇÒÁÍÍÁ ÐÏÐÒÏÓÉÔ ××ÅÓÔÉ ÉÈ Ó ÔÅÒÍÉÎÁÌÁ.\n"
+"\n"
+"APOP:\n"
+" ôÏ ÖÅ, ÞÔÏ É POP, ÎÏ Ó ÉÓÐÏÌØÚÏ×ÁÎÉÅÍ ÐÒÏÔÏËÏÌÁ Authenticated POP:\n"
+"    apop username:password at server\n"
+"\n"
+"IMAP:\n"
+" ðÏÌÕÞÉÔØ ÐÏÞÔÕ Ó ÓÅÒ×ÅÒÁ IMAP.  ðÏÞÔÏ×ÙÊ ÑÝÉË ÕËÁÚÙ×ÁÅÔÓÑ ÔÁË ÖÅ,\n"
+" ËÁË É ÄÌÑ POP-ÓÅÒ×ÅÒÁ:\n"
+"    imap username:password at server\n"
+" ðÒÉ ÉÓÐÏÌØÚÏ×ÁÎÉÉ IMAP ÍÏÖÎÏ ÕËÁÚÁÔØ ÉÍÑ ÐÁÐËÉ ×ÈÏÄÎÏÊ ÐÏÞÔÙ,\n"
+" ÅÓÌÉ ÏÎÏ ÏÔÌÉÞÁÅÔÓÑ ÏÔ ÏÂÙÞÎÏÇÏ (\"INBOX\"):\n"
+"    imap username:password at server folder\n"
+"\n"
+"IMAPS:\n"
+" ðÏÌÕÞÉÔØ ÐÏÞÔÕ Ó ÓÅÒ×ÅÒÁ IMAP, ÐÏÚ×ÏÌÑÀÝÅÇÏ ÛÉÆÒÏ×ÁÎÎÙÅ ÓÏÅÄÉÎÅÎÉÑ.\n"
+" ðÏÞÔÏ×ÙÊ ÑÝÉË ÕËÁÚÙ×ÁÅÔÓÑ ÔÁË ÖÅ, ËÁË É ÄÌÑ ÏÂÙÞÎÏÇÏ IMAP-ÓÅÒ×ÅÒÁ:\n"
+"    imaps username:password at server [mailbox]\n"
+"\n"
+
+#: ../roundup/scripts/roundup_mailgw.py:147
+msgid "Error: not enough source specification information"
+msgstr "ïÛÉÂËÁ: ÎÅ ÕËÁÚÁÎ ÐÕÔØ Ë ÐÏÞÔÏ×ÏÍÕ ÑÝÉËÕ"
+
+#: ../roundup/scripts/roundup_mailgw.py:163
+msgid "Error: pop specification not valid"
+msgstr "ïÛÉÂËÁ: ÎÅÐÒÁ×ÉÌØÎÙÊ ÁÄÒÅÓ pop-ÓÅÒ×ÅÒÁ"
+
+#: ../roundup/scripts/roundup_mailgw.py:170
+msgid "Error: apop specification not valid"
+msgstr "ïÛÉÂËÁ: ÎÅÐÒÁ×ÉÌØÎÙÊ ÁÄÒÅÓ apop-ÓÅÒ×ÅÒÁ"
+
+#: ../roundup/scripts/roundup_mailgw.py:184
+msgid ""
+"Error: The source must be either \"mailbox\", \"pop\", \"apop\", \"imap\" or "
+"\"imaps\""
+msgstr ""
+"ïÛÉÂËÁ: ôÉÐ ÐÏÞÔÏ×ÏÇÏ ÑÝÉËÁ ÄÏÌÖÅÎ ÂÙÔØ \"mailbox\", \"pop\", \"apop\", "
+"\"imap\" ÉÌÉ \"imaps\""
+
+#: ../roundup/scripts/roundup_server.py:157
+msgid ""
+"<html><head><title>Roundup trackers index</title></head>\n"
+"<body><h1>Roundup trackers index</h1><ol>\n"
+msgstr ""
+"<html><head><title>óÐÉÓÏË ÔÒÅËÅÒÏ× Roundup</title></head>\n"
+"<body><h1>óÐÉÓÏË ÔÒÅËÅÒÏ× Roundup</h1><ol>\n"
+
+#: ../roundup/scripts/roundup_server.py:287
+#, python-format
+msgid "Error: %s: %s"
+msgstr "ïÛÉÂËÁ: %s: %s"
+
+#: ../roundup/scripts/roundup_server.py:297
+msgid "WARNING: ignoring \"-g\" argument, not root"
+msgstr ""
+"÷îéíáîéå: ÐÁÒÁÍÅÔÒ \"-g\" ÎÅ ÉÓÐÏÌØÚÕÅÔÓÑ, ÏÎ ÒÁÚÒÅÛÅÎ ÔÏÌØËÏ ÄÌÑ "
+"ÐÏÌØÚÏ×ÁÔÅÌÑ root"
+
+#: ../roundup/scripts/roundup_server.py:303
+msgid "Can't change groups - no grp module"
+msgstr "ðÏÄÍÅÎÁ ÇÒÕÐÐÙ ÎÅ×ÏÚÍÏÖÎÁ - ÎÕÖÅÎ ÍÏÄÕÌØ grp"
+
+#: ../roundup/scripts/roundup_server.py:312
+#, python-format
+msgid "Group %(group)s doesn't exist"
+msgstr "çÒÕÐÐÁ %(group)s ÎÅ ÓÕÝÅÓÔ×ÕÅÔ"
+
+#: ../roundup/scripts/roundup_server.py:323
+msgid "Can't run as root!"
+msgstr "úÁÐÕÓË ÓÅÒ×ÅÒÁ Ó ÐÏÌÎÏÍÏÞÉÑÍÉ ÐÏÌØÚÏ×ÁÔÅÌÑ root ÚÁÐÒÅÝÅÎ!"
+
+#: ../roundup/scripts/roundup_server.py:326
+msgid "WARNING: ignoring \"-u\" argument, not root"
+msgstr ""
+"÷îéíáîéå: ÐÁÒÁÍÅÔÒ \"-u\" ÎÅ ÉÓÐÏÌØÚÕÅÔÓÑ, ÏÎ ÒÁÚÒÅÛÅÎ ÔÏÌØËÏ ÄÌÑ "
+"ÐÏÌØÚÏ×ÁÔÅÌÑ root"
+
+#: ../roundup/scripts/roundup_server.py:331
+msgid "Can't change users - no pwd module"
+msgstr "ðÏÄÍÅÎÁ ÐÏÌØÚÏ×ÁÔÅÌÑ ÎÅ×ÏÚÍÏÖÎÁ - ÎÕÖÅÎ ÍÏÄÕÌØ pwd"
+
+#: ../roundup/scripts/roundup_server.py:340
+#, python-format
+msgid "User %(user)s doesn't exist"
+msgstr "ðÏÌØÚÏ×ÁÔÅÌØ %(user)s ÎÅ ÓÕÝÅÓÔ×ÕÅÔ"
+
+#: ../roundup/scripts/roundup_server.py:471
+#, python-format
+msgid "Multiprocess mode \"%s\" is not available, switching to single-process"
+msgstr "òÅÖÉÍ \"%s\" ÎÅÄÏÓÔÕÐÅÎ, ÐÅÒÅËÌÀÞÁÅÍÓÑ × ÏÄÎÏÚÁÄÁÞÎÙÊ ÒÅÖÉÍ"
+
+#: ../roundup/scripts/roundup_server.py:494
+#, python-format
+msgid "Unable to bind to port %s, port already in use."
+msgstr "îÅ×ÏÚÍÏÖÎÏ ÕÓÔÁÎÏ×ÉÔØ ÓÅÒ×ÅÒ ÎÁ ÐÏÒÔÕ %s, ÐÏÒÔ ÕÖÅ ÚÁÎÑÔ."
+
+#: ../roundup/scripts/roundup_server.py:562
+msgid ""
+" -c <Command>  Windows Service options.\n"
+"               If you want to run the server as a Windows Service, you\n"
+"               must use configuration file to specify tracker homes.\n"
+"               Logfile option is required to run Roundup Tracker service.\n"
+"               Typing \"roundup-server -c help\" shows Windows Services\n"
+"               specifics."
+msgstr ""
+" -c <ËÏÍÁÎÄÁ>  ËÌÀÞ ÓÅÒ×ÉÓÁ Windows.\n"
+"               åÓÌÉ ×Ù ÓÏÂÉÒÁÅÔÅÓØ ÚÁÐÕÓËÁÔØ ÓÅÒ×ÅÒ ËÁË ÓÅÒ×ÉÓ Windows,\n"
+"               ×Ù ÄÏÌÖÎÙ ÉÓÐÏÌØÚÏ×ÁÔØ ËÏÎÆÉÇÕÒÁÃÉÏÎÎÙÊ ÆÁÊÌ ÓÅÒ×ÅÒÁ\n"
+"               ÄÌÑ ÚÁÄÁÎÉÑ ÄÏÍÁÛÎÉÈ ËÁÔÁÌÏÇÏ× ÔÒÅËÅÒÏ×.\n"
+"               äÌÑ ÚÁÐÕÓËÁ × ÒÅÖÉÍÅ ÓÅÒ×ÉÓÁ Windows ÎÅÏÂÈÏÄÉÍÏ ÕËÁÚÁÔØ.\n"
+"               ÆÁÊÌ ÐÒÏÔÏËÏÌÁ.  ëÏÍÁÎÄÁ 'roundup-server -c help'\n"
+"               ×ÙÄÁÅÔ ÓÐÒÁ×ËÕ Ï ËÏÍÁÎÄÎÏÊ ÓÔÒÏËÅ ÓÅÒ×ÉÓÁ Windows."
+
+#: ../roundup/scripts/roundup_server.py:569
+msgid ""
+" -u <UID>      runs the Roundup web server as this UID\n"
+" -g <GID>      runs the Roundup web server as this GID\n"
+" -d <PIDfile>  run the server in the background and write the server's PID\n"
+"               to the file indicated by PIDfile. The -l option *must* be\n"
+"               specified if -d is used."
+msgstr ""
+" -u <UID>      ÚÁÐÕÓÔÉÔØ cÅÒ×ÅÒ Roundup Ó ÐÒÁ×ÁÍÉ ÐÏÌØÚÏ×ÁÔÅÌÑ UID\n"
+" -g <GID>      ÚÁÐÕÓÔÉÔØ ÓÅÒ×ÅÒ Roundup × ÇÒÕÐÐÅ GID\n"
+" -d <PIDfile>  ÚÁÐÉÓÁÔØ ÎÏÍÅÒ ÐÒÏÃÅÓÓÁ (pid) ÓÅÒ×ÅÒÁ × ÆÁÊÌ PIDfile\n"
+"               É ÚÁÐÕÓÔÉÔØ ÓÅÒ×ÅÒ × ÆÏÎÏ×ÏÍ ÒÅÖÉÍÅ.  åÓÌÉ ÕËÁÚÁÎÏ \"-d\",\n"
+"               ÆÁÊÌ ÐÒÏÔÏËÏÌÁ *ÏÂÑÚÁÔÅÌØÎÏ* ÄÏÌÖÅÎ ÂÙÔØ ÚÁÄÁÎ ËÌÀÞÏÍ \"-l\""
+
+#: ../roundup/scripts/roundup_server.py:576
+#, python-format
+msgid ""
+"%(message)sUsage: roundup-server [options] [name=tracker home]*\n"
+"\n"
+"Options:\n"
+" -v            print the Roundup version number and exit\n"
+" -h            print this text and exit\n"
+" -S            create or update configuration file and exit\n"
+" -C <fname>    use configuration file <fname>\n"
+" -n <name>     set the host name of the Roundup web server instance\n"
+" -p <port>     set the port to listen on (default: %(port)s)\n"
+" -l <fname>    log to the file indicated by fname instead of stderr/stdout\n"
+" -N            log client machine names instead of IP addresses (much "
+"slower)\n"
+" -t <mode>     multiprocess mode (default: %(mp_def)s).\n"
+"               Allowed values: %(mp_types)s.\n"
+"%(os_part)s\n"
+"\n"
+"Long options:\n"
+" --version          print the Roundup version number and exit\n"
+" --help             print this text and exit\n"
+" --save-config      create or update configuration file and exit\n"
+" --config <fname>   use configuration file <fname>\n"
+" All settings of the [main] section of the configuration file\n"
+" also may be specified in form --<name>=<value>\n"
+"\n"
+"Examples:\n"
+"\n"
+" roundup-server -S -C /opt/roundup/etc/roundup-server.ini \\\n"
+"    -n localhost -p 8917 -l /var/log/roundup.log \\\n"
+"    support=/var/spool/roundup-trackers/support\n"
+"\n"
+" roundup-server -C /opt/roundup/etc/roundup-server.ini\n"
+"\n"
+" roundup-server support=/var/spool/roundup-trackers/support\n"
+"\n"
+" roundup-server -d /var/run/roundup.pid -l /var/log/roundup.log \\\n"
+"    support=/var/spool/roundup-trackers/support\n"
+"\n"
+"Configuration file format:\n"
+"   Roundup Server configuration file has common .ini file format.\n"
+"   Configuration file created with 'roundup-server -S' contains\n"
+"   detailed explanations for each option.  Please see that file\n"
+"   for option descriptions.\n"
+"\n"
+"How to use \"name=tracker home\":\n"
+"   These arguments set the tracker home(s) to use. The name is how the\n"
+"   tracker is identified in the URL (it's the first part of the URL path).\n"
+"   The tracker home is the directory that was identified when you did\n"
+"   \"roundup-admin init\". You may specify any number of these name=home\n"
+"   pairs on the command-line. Make sure the name part doesn't include\n"
+"   any url-unsafe characters like spaces, as these confuse IE.\n"
+msgstr ""
+"%(message)s÷ÙÚÏ×: roundup-server [ËÌÀÞÉ] [ÉÍÑ=ËÁÔÁÌÏÇ]*\n"
+"\n"
+"ëÌÀÞÉ:\n"
+" -v            ÐÏËÁÚÁÔØ ÎÏÍÅÒ ×ÅÒÓÉÉ É ×ÙÊÔÉ\n"
+" -h            ÐÏËÁÚÁÔØ ÜÔÏ ÓÏÏÂÝÅÎÉÅ É ×ÙÊÔÉ\n"
+" -S            ÓÏÚÄÁÔØ ÉÌÉ ÏÂÎÏ×ÉÔØ ÆÁÊÌ ËÏÎÆÉÇÕÒÁÃÉÉ ÓÅÒ×ÅÒÁ É ×ÙÊÔÉ\n"
+" -C <ÆÁÊÌ>     ÉÓÐÏÌØÚÏ×ÁÔØ <ÆÁÊÌ> ËÏÎÆÉÇÕÒÁÃÉÉ ÓÅÒ×ÅÒÁ\n"
+" -n <ÁÄÒÅÓ>    ÕÓÔÁÎÏ×ÉÔØ ÁÄÒÅÓ ×ÅÂ-ÓÅÒ×ÅÒÁ Roundup\n"
+" -p <ÐÏÒÔ>     ÉÚÍÅÎÉÔØ ÎÏÍÅÒ ÐÏÒÔÁ (ÐÏ ÕÍÏÌÞÁÎÉÀ - %(port)s)\n"
+" -l <ÆÁÊÌ>     ×ÅÓÔÉ ÐÒÏÔÏËÏÌ × ÕËÁÚÁÎÎÏÍ ÆÁÊÌÅ (×ÍÅÓÔÏ stderr/stdout)\n"
+" -N            ÐÒÏÔÏËÏÌÉÒÏ×ÁÔØ ÉÍÅÎÁ ÍÁÛÉÎ ËÌÉÅÎÔÏ× ×ÍÅÓÔÏ IP-ÁÄÒÅÓÏ×\n"
+"               (ÓÉÌØÎÏ ÚÁÍÅÄÌÑÅÔ ÒÁÂÏÔÕ).\n"
+" -t <ÒÅÖÉÍ>    ÒÅÖÉÍ ÍÎÏÇÏÚÁÄÁÞÎÏÓÔÉ (ÐÏ ÕÍÏÌÞÁÎÉÀ - %(mp_def)s).\n"
+"               äÏÓÔÕÐÎÙÅ ÒÅÖÉÍÙ: %(mp_types)s.\n"
+"%(os_part)s\n"
+"\n"
+"òÁÓÛÉÒÅÎÎÙÅ ËÌÀÞÉ:\n"
+" --version          ÐÏËÁÚÁÔØ ÎÏÍÅÒ ×ÅÒÓÉÉ É ×ÙÊÔÉ\n"
+" --help             ÐÏËÁÚÁÔØ ÜÔÏ ÓÏÏÂÝÅÎÉÅ É ×ÙÊÔÉ\n"
+" --save-config      ÓÏÚÄÁÔØ ÉÌÉ ÏÂÎÏ×ÉÔØ ÆÁÊÌ ËÏÎÆÉÇÕÒÁÃÉÉ ÓÅÒ×ÅÒÁ É ×ÙÊÔÉ\n"
+" --config <ÆÁÊÌ>    ÉÓÐÏÌØÚÏ×ÁÔØ <ÆÁÊÌ> ËÏÎÆÉÇÕÒÁÃÉÉ ÓÅÒ×ÅÒÁ\n"
+" --help             print this text and exit\n"
+" ëÒÏÍÅ ÜÔÏÇÏ, ×ÓÅ ÎÁÓÔÒÏÊËÉ ÓÅËÃÉÉ [main] × ËÏÎÆÉÇÕÒÁÃÉÏÎÎÏÍ ÆÁÊÌÅ\n"
+" ÍÏÇÕÔ ÚÁÄÁ×ÁÔØÓÑ ËÁË ËÌÀÞÉ ËÏÍÁÎÄÎÏÊ ÓÔÒÏËÉ × ×ÉÄÅ --<ÉÍÑ>=<ÚÎÁÞÅÎÉÅ>\n"
+"\n"
+"ðÒÉÍÅÒÙ:\n"
+"\n"
+" roundup-server -S -C /opt/roundup/etc/roundup-server.ini \\\n"
+"    -n localhost -p 8917 -l /var/log/roundup.log \\\n"
+"    support=/var/spool/roundup-trackers/support\n"
+"\n"
+" roundup-server -C /opt/roundup/etc/roundup-server.ini\n"
+"\n"
+" roundup-server support=/var/spool/roundup-trackers/support\n"
+"\n"
+" roundup-server -d /var/run/roundup.pid -l /var/log/roundup.log \\\n"
+"    support=/var/spool/roundup-trackers/support\n"
+"\n"
+"æÏÒÍÁÔ ËÏÎÆÉÇÕÒÁÃÉÏÎÎÏÇÏ ÆÁÊÌÁ:\n"
+"   æÁÊÌ ËÏÎÆÉÇÕÒÁÃÉÉ ÓÅÒ×ÅÒÁ Roundup ÉÍÅÅÔ ÏÂÙÞÎÙÊ ÆÏÒÍÁÔ ini-ÆÁÊÌÏ×.\n"
+"   ëÏÎÆÉÇÕÒÁÃÉÏÎÎÙÊ ÆÁÊÌ, ÓÏÚÄÁÎÎÙÊ ËÏÍÁÎÄÏÊ 'roundup-server -S',\n"
+"   ÓÏÄÅÒÖÉÔ ÐÏÄÒÏÂÎÙÅ ÏÐÉÓÁÎÉÑ ×ÓÅÈ ÎÁÓÔÒÏÅË.\n"
+"\n"
+"ðÁÒÁÍÅÔÒÙ \"ÉÍÑ=ËÁÔÁÌÏÇ\"\n"
+"   ÚÁÄÁÀÔ ÔÒÅËÅÒ (ÉÌÉ ÔÒÅËÅÒÙ), ÏÂÓÌÕÖÉ×ÁÅÍÙÅ ÜÔÉÍ ÓÅÒ×ÅÒÏÍ.\n"
+"   éÍÑ ÔÒÅËÅÒÁ Ñ×ÌÑÅÔÓÑ ÞÁÓÔØÀ URL (ÐÅÒ×ÁÑ ÞÁÓÔØ ÐÕÔÉ × URL).\n"
+"   ëÁÔÁÌÏÇ ÕËÁÚÙ×ÁÅÔ ÎÁ ÒÁÓÐÏÌÏÖÅÎÉÅ ÄÁÎÎÙÈ ÔÒÅËÅÒÁ.  üÔÏ - ÔÏÔ\n"
+"   ËÁÔÁÌÏÇ, ËÏÔÏÒÙÊ ×Ù ÕËÁÚÙ×ÁÌÉ ÐÒÉ ×ÙÐÏÌÎÅÎÉÉ ËÏÍÁÎÄÙ\n"
+"   'roundup-admin init'.\n"
+"   ÷Ù ÍÏÖÅÔÅ ÕËÁÚÁÔØ × ËÏÍÁÎÄÎÏÊ ÓÔÒÏËÅ ÓËÏÌØËÏ ÕÇÏÄÎÏ ÐÁÒ ÉÍÑ=ËÁÔÁÌÏÇ.\n"
+"   óÌÅÄÉÔÅ ÚÁ ÔÅÍ, ÞÔÏÂÙ × ÉÍÅÎÁÈ ÔÒÅËÅÒÏ× ÎÅ ÂÙÌÏ ÓÉÍ×ÏÌÏ×, ËÏÔÏÒÙÅ\n"
+"   ÎÅ ÍÏÇÕÔ ÉÓÐÏÌØÚÏ×ÁÔØÓÑ × URL (ÐÒÏÂÅÌÙ, ÒÕÓÓËÉÅ ÂÕË×Ù É ÐÒÏÞ.),\n"
+"   ÐÏÔÏÍÕ ÞÔÏ ÔÁËÉÅ ÉÍÅÎÁ ÓÂÉ×ÁÀÔ Ó ÔÏÌËÕ ÎÅËÏÔÏÒÙÅ ÂÒÁÕÚÅÒÙ ÔÉÐÁ IE.\n"
+
+#: ../roundup/scripts/roundup_server.py:724
+msgid "Instances must be name=home"
+msgstr "óÐÉÓÏË ÔÒÅËÅÒÏ× ÄÏÌÖÅÎ ÂÙÔØ × ÆÏÒÍÁÔÅ ÉÍÑ=ËÁÔÁÌÏÇ"
+
+#: ../roundup/scripts/roundup_server.py:738
+#, python-format
+msgid "Configuration saved to %s"
+msgstr "ëÏÎÆÉÇÕÒÁÃÉÑ ÚÁÐÉÓÁÎÁ × %s"
+
+#: ../roundup/scripts/roundup_server.py:756
+msgid "Sorry, you can't run the server as a daemon on this Operating System"
+msgstr ""
+"éÚ×ÉÎÉÔÅ, × ÜÔÏÊ ÏÐÅÒÁÃÉÏÎÎÏÊ ÓÉÓÔÅÍÅ ÒÁÂÏÔÁ × ÆÏÎÏ×ÏÍ ÒÅÖÉÍÅ ÎÅ×ÏÚÍÏÖÎÁ"
+
+#: ../roundup/scripts/roundup_server.py:768
+#, python-format
+msgid "Roundup server started on %(HOST)s:%(PORT)s"
+msgstr "óÅÒ×ÅÒ Roundup ÇÏÔÏ× Ë ÒÁÂÏÔÅ ÐÏ ÁÄÒÅÓÕ %(HOST)s:%(PORT)s"
+
+#: ../templates/classic/html/_generic.collision.html:4
+#: ../templates/minimal/html/_generic.collision.html:4
+msgid "${class} Edit Collision - ${tracker}"
+msgstr "ëÏÎÆÌÉËÔ ÒÅÄÁËÔÉÒÏ×ÁÎÉÑ ${class} - ${tracker}"
+
+#: ../templates/classic/html/_generic.collision.html:7
+#: ../templates/minimal/html/_generic.collision.html:7
+msgid "${class} Edit Collision"
+msgstr "ëÏÎÆÌÉËÔ ÒÅÄÁËÔÉÒÏ×ÁÎÉÑ ${class}"
+
+#: ../templates/classic/html/_generic.collision.html:14
+#: ../templates/minimal/html/_generic.collision.html:14
+msgid ""
+"\n"
+"  There has been a collision. Another user updated this node\n"
+"  while you were editing. Please <a href='${context}'>reload</a>\n"
+"  the node and review your edits.\n"
+msgstr ""
+"\n"
+"  ïÂÎÁÒÕÖÅÎ ËÏÎÆÌÉËÔ ÒÅÄÁËÔÉÒÏ×ÁÎÉÑ.  ðÏËÁ ×Ù ÚÁÐÏÌÎÑÌÉ ÜÔÕ\n"
+"  ÆÏÒÍÕ, ÄÒÕÇÏÊ ÐÏÌØÚÏ×ÁÔÅÌØ ÉÚÍÅÎÉÌ ÏÂßÅËÔ ÂÁÚÙ ÄÁÎÎÙÈ.\n"
+"  <a href='${context}>ðÅÒÅÞÉÔÁÊÔÅ</a> ÆÏÒÍÕ É ×ÎÅÓÉÔÅ ÉÚÍÅÎÅÎÉÑ\n"
+"  ÚÁÎÏ×Ï, ÐÏÖÁÌÕÊÓÔÁ.\n"
+
+#: ../templates/classic/html/_generic.help.html:9
+#: ../templates/minimal/html/_generic.help.html:9
+msgid "${property} help - ${tracker}"
+msgstr "óÐÒÁ×ËÁ ÐÏ ÐÏÌÀ \"${property}\" - ${tracker}"
+
+#: ../templates/classic/html/_generic.help.html:31
+#: ../templates/minimal/html/_generic.help.html:31
+msgid " Cancel "
+msgstr " ïÔÍÅÎÉÔØ "
+
+#: ../templates/classic/html/_generic.help.html:34
+#: ../templates/minimal/html/_generic.help.html:34
+msgid " Apply "
+msgstr " ðÒÉÍÅÎÉÔØ "
+
+#: ../templates/classic/html/_generic.help.html:41
+#: ../templates/classic/html/issue.index.html:73
+#: ../templates/minimal/html/_generic.help.html:41
+msgid "&lt;&lt; previous"
+msgstr "&lt;&lt; ÐÒÅÄÙÄÕÝÉÅ"
+
+#: ../templates/classic/html/_generic.help.html:52
+#: ../templates/classic/html/issue.index.html:81
+#: ../templates/minimal/html/_generic.help.html:52
+msgid "${start}..${end} out of ${total}"
+msgstr "${start}..${end} ÉÚ ${total}"
+
+#: ../templates/classic/html/_generic.help.html:56
+#: ../templates/classic/html/issue.index.html:84
+#: ../templates/minimal/html/_generic.help.html:56
+msgid "next &gt;&gt;"
+msgstr "ÓÌÅÄÕÀÝÉÅ &gt;&gt;"
+
+#: ../templates/classic/html/_generic.index.html:6
+#: ../templates/classic/html/_generic.item.html:4
+#: ../templates/minimal/html/_generic.index.html:6
+#: ../templates/minimal/html/_generic.item.html:4
+msgid "${class} editing - ${tracker}"
+msgstr "òÅÄÁËÔÉÒÏ×ÁÎÉÅ ${class} - ${tracker}"
+
+#: ../templates/classic/html/_generic.index.html:9
+#: ../templates/classic/html/_generic.item.html:7
+#: ../templates/minimal/html/_generic.index.html:9
+#: ../templates/minimal/html/_generic.item.html:7
+msgid "${class} editing"
+msgstr "òÅÄÁËÔÉÒÏ×ÁÎÉÅ ${class}"
+
+#: ../templates/classic/html/_generic.index.html:14
+#: ../templates/classic/html/_generic.item.html:12
+#: ../templates/classic/html/file.item.html:9
+#: ../templates/classic/html/issue.index.html:16
+#: ../templates/classic/html/issue.item.html:28
+#: ../templates/classic/html/msg.item.html:26
+#: ../templates/classic/html/user.index.html:9
+#: ../templates/classic/html/user.item.html:28
+#: ../templates/minimal/html/_generic.index.html:14
+#: ../templates/minimal/html/_generic.item.html:12
+#: ../templates/minimal/html/user.index.html:9
+#: ../templates/minimal/html/user.item.html:28
+#: ../templates/minimal/html/user.register.html:14
+msgid "You are not allowed to view this page."
+msgstr "õ ×ÁÓ ÎÅÔ ÒÁÚÒÅÛÅÎÉÑ ÎÁ ÐÒÏÓÍÏÔÒ ÜÔÏÊ ÓÔÒÁÎÉÃÙ."
+
+#: ../templates/classic/html/_generic.index.html:22
+#: ../templates/minimal/html/_generic.index.html:22
+msgid ""
+"<p class=\"form-help\"> You may edit the contents of the ${classname} class "
+"using this form. Commas, newlines and double quotes (\") must be handled "
+"delicately. You may include commas and newlines by enclosing the values in "
+"double-quotes (\"). Double quotes themselves must be quoted by doubling "
+"(\"\"). </p> <p class=\"form-help\"> Multilink properties have their "
+"multiple values colon (\":\") separated (... ,\"one:two:three\", ...) </p> "
+"<p class=\"form-help\"> Remove entries by deleting their line. Add new "
+"entries by appending them to the table - put an X in the id column. </p>"
+msgstr ""
+"<p class=\"form-help\"> ðÒÉ ÐÏÍÏÝÉ ÜÔÏÊ ÆÏÒÍÙ ×Ù ÍÏÖÅÔÅ ÉÚÍÅÎÉÔØ ÓÏÄÅÒÖÉÍÏÅ "
+"ËÌÁÓÓÁ ${classname}. âÕÄØÔÅ ÏÓÔÏÒÏÖÎÙ Ó ÚÁÐÑÔÙÍÉ, ÐÅÒÅ×ÏÄÁÍÉ ÓÔÒÏË É "
+"Ä×ÏÊÎÙÍÉ ËÁ×ÙÞËÁÍÉ (\"). úÎÁÞÅÎÉÑ, ÓÏÄÅÒÖÁÝÉÅ ÚÁÐÑÔÙÅ É ÐÅÒÅ×ÏÄÙ ÓÔÒÏË, "
+"ÄÏÌÖÎÙ ÂÙÔØ ÚÁËÌÀÞÅÎÙ × Ä×ÏÊÎÙÅ ËÁ×ÙÞËÉ (\"). ä×ÏÊÎÙÅ ËÁ×ÙÞËÉ × ÚÎÁÞÅÎÉÑÈ "
+"ÄÏÌÖÎÙ ÂÙÔØ ÕÄ×ÏÅÎÙ (\"\"). </p> <p class=\"form-help\"> úÎÁÞÅÎÉÑ "
+"ÍÎÏÖÅÓÔ×ÅÎÎÙÈ ÓÓÙÌÏË (multilink properties) ÒÁÚÄÅÌÑÀÔÓÑ Ä×ÏÅÔÏÞÉÅÍ (... ,"
+"\"ÒÁÚ:Ä×Á:ÔÒÉ\", ...) </p> <p class=\"form-help\"> äÌÑ ÔÏÇÏ, ÞÔÏÂÙ "
+"ÕÎÉÞÔÏÖÉÔØ ÚÁÐÉÓØ, ÕÄÁÌÉÔÅ ÓÏÏÔ×ÅÔÓÔ×ÕÀÝÕÀ ÓÔÒÏËÕ.  îÏ×ÙÅ ÚÁÐÉÓÉ "
+"ÄÏÐÉÓÙ×ÁÀÔÓÑ × ËÏÎÅà ÔÁÂÌÉÃÙ. ÷ÍÅÓÔÏ ÉÄÅÎÔÉÆÉËÁÔÏÒÁ (id) ÎÏ×ÙÈ ÚÁÐÉÓÅÊ ÎÕÖÎÏ "
+"×ÐÉÓÙ×ÁÔØ ÌÁÔÉÎÓËÕÀ ÂÕË×Õ \"X\". </p>"
+
+#: ../templates/classic/html/_generic.index.html:44
+#: ../templates/minimal/html/_generic.index.html:44
+msgid "Edit Items"
+msgstr "éÚÍÅÎÉÔØ"
+
+#: ../templates/classic/html/file.index.html:4
+msgid "List of files - ${tracker}"
+msgstr "óÐÉÓÏË ÆÁÊÌÏ× - ${tracker}"
+
+#: ../templates/classic/html/file.index.html:5
+msgid "List of files"
+msgstr "óÐÉÓÏË ÆÁÊÌÏ×"
+
+#: ../templates/classic/html/file.index.html:10
+msgid "Download"
+msgstr "óËÁÞÁÔØ"
+
+#: ../templates/classic/html/file.index.html:11
+#: ../templates/classic/html/file.item.html:22
+msgid "Content Type"
+msgstr "ôÉÐ ÆÁÊÌÁ"
+
+#: ../templates/classic/html/file.index.html:12
+msgid "Uploaded By"
+msgstr "úÁÇÒÕÚÉÌ"
+
+#: ../templates/classic/html/file.index.html:13
+#: ../templates/classic/html/msg.item.html:43
+msgid "Date"
+msgstr "äÁÔÁ"
+
+#: ../templates/classic/html/file.item.html:2
+msgid "File display - ${tracker}"
+msgstr "ðÒÏÓÍÏÔÒ ÆÁÊÌÁ - ${tracker}"
+
+#: ../templates/classic/html/file.item.html:4
+msgid "File display"
+msgstr "ðÒÏÓÍÏÔÒ ÆÁÊÌÁ"
+
+#: ../templates/classic/html/file.item.html:18
+#: ../templates/classic/html/user.item.html:39
+#: ../templates/classic/html/user.register.html:17
+msgid "Name"
+msgstr "éÍÑ"
+
+#: ../templates/classic/html/file.item.html:40
+msgid "download"
+msgstr "ÓËÁÞÁÔØ"
+
+#: ../templates/classic/html/home.classlist.html:2
+#: ../templates/minimal/html/home.classlist.html:2
+msgid "List of classes - ${tracker}"
+msgstr "óÐÉÓÏË ËÌÁÓÓÏ× - ${tracker}"
+
+#: ../templates/classic/html/home.classlist.html:4
+#: ../templates/minimal/html/home.classlist.html:4
+msgid "List of classes"
+msgstr "óÐÉÓÏË ËÌÁÓÓÏ×"
+
+#: ../templates/classic/html/issue.index.html:7
+msgid "List of issues - ${tracker}"
+msgstr "óÐÉÓÏË ÚÁÄÁÞ - ${tracker}"
+
+#: ../templates/classic/html/issue.index.html:11
+msgid "List of issues"
+msgstr "óÐÉÓÏË ÚÁÄÁÞ"
+
+#: ../templates/classic/html/issue.index.html:22
+#: ../templates/classic/html/issue.item.html:44
+msgid "Priority"
+msgstr "ðÒÉÏÒÉÔÅÔ"
+
+#: ../templates/classic/html/issue.index.html:23
+msgid "ID"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:24
+msgid "Creation"
+msgstr "äÁÔÁ ÓÏÚÄÁÎÉÑ"
+
+#: ../templates/classic/html/issue.index.html:25
+msgid "Activity"
+msgstr "äÅÊÓÔ×ÉÅ"
+
+#: ../templates/classic/html/issue.index.html:26
+msgid "Actor"
+msgstr "÷ÙÐÏÌÎÉÌ"
+
+#: ../templates/classic/html/issue.index.html:27
+msgid "Topic"
+msgstr "ôÅÍÁ"
+
+#: ../templates/classic/html/issue.index.html:28
+#: ../templates/classic/html/issue.item.html:39
+msgid "Title"
+msgstr "úÁÇÌÁ×ÉÅ"
+
+#: ../templates/classic/html/issue.index.html:29
+#: ../templates/classic/html/issue.item.html:46
+msgid "Status"
+msgstr "óÔÁÔÕÓ"
+
+#: ../templates/classic/html/issue.index.html:30
+msgid "Creator"
+msgstr "á×ÔÏÒ"
+
+#: ../templates/classic/html/issue.index.html:31
+msgid "Assigned&nbsp;To"
+msgstr "éÓÐÏÌÎÉÔÅÌØ"
+
+#: ../templates/classic/html/issue.index.html:97
+msgid "Download as CSV"
+msgstr "óËÁÞÁÔØ CSV"
+
+#: ../templates/classic/html/issue.index.html:105
+msgid "Sort on:"
+msgstr "óÏÒÔÉÒÏ×ËÁ:"
+
+#: ../templates/classic/html/issue.index.html:108
+#: ../templates/classic/html/issue.index.html:125
+msgid "- nothing -"
+msgstr "- ÎÅÔ -"
+
+#: ../templates/classic/html/issue.index.html:116
+#: ../templates/classic/html/issue.index.html:133
+msgid "Descending:"
+msgstr "ðÏ ÕÂÙ×ÁÎÉÀ:"
+
+#: ../templates/classic/html/issue.index.html:122
+msgid "Group on:"
+msgstr "çÒÕÐÐÉÒÏ×ËÁ:"
+
+#: ../templates/classic/html/issue.index.html:139
+msgid "Redisplay"
+msgstr "ïÂÎÏ×ÉÔØ"
+
+#: ../templates/classic/html/issue.item.html:7
+msgid "Issue ${id}: ${title} - ${tracker}"
+msgstr "úÁÄÁÞÁ ${id}: ${title} - ${tracker}"
+
+#: ../templates/classic/html/issue.item.html:10
+msgid "New Issue - ${tracker}"
+msgstr "îÏ×ÁÑ ÚÁÄÁÞÁ - ${tracker}"
+
+#: ../templates/classic/html/issue.item.html:14
+msgid "New Issue"
+msgstr "îÏ×ÁÑ ÚÁÄÁÞÁ"
+
+#: ../templates/classic/html/issue.item.html:16
+msgid "New Issue Editing"
+msgstr "òÅÇÉÓÔÒÁÃÉÑ ÎÏ×ÏÊ ÚÁÄÁÞÉ"
+
+#: ../templates/classic/html/issue.item.html:19
+msgid "Issue${id}"
+msgstr "úÁÄÁÞÁ ${id}"
+
+#: ../templates/classic/html/issue.item.html:22
+msgid "Issue${id} Editing"
+msgstr "òÅÄÁËÔÉÒÏ×ÁÎÉÅ ÚÁÄÁÞÉ ${id}"
+
+#: ../templates/classic/html/issue.item.html:51
+msgid "Superseder"
+msgstr "úÁÍÅÝÅÎÉÅ"
+
+#: ../templates/classic/html/issue.item.html:56
+msgid "View: ${link}"
+msgstr "ðÒÏÓÍÏÔÒ: ${link}"
+
+#: ../templates/classic/html/issue.item.html:60
+msgid "Nosy List"
+msgstr "òÁÓÓÙÌËÁ ÉÚ×ÅÝÅÎÉÊ"
+
+#: ../templates/classic/html/issue.item.html:69
+msgid "Assigned To"
+msgstr "éÓÐÏÌÎÉÔÅÌØ"
+
+#: ../templates/classic/html/issue.item.html:71
+msgid "Topics"
+msgstr "ôÅÍÙ"
+
+#: ../templates/classic/html/issue.item.html:79
+msgid "Change Note"
+msgstr "úÁÍÅÔËÉ"
+
+#: ../templates/classic/html/issue.item.html:87
+msgid "File"
+msgstr "æÁÊÌ"
+
+#: ../templates/classic/html/issue.item.html:99
+msgid "Make a copy"
+msgstr "óËÏÐÉÒÏ×ÁÔØ"
+
+#: ../templates/classic/html/issue.item.html:107
+#: ../templates/classic/html/user.item.html:106
+#: ../templates/classic/html/user.register.html:69
+#: ../templates/minimal/html/user.item.html:86
+msgid ""
+"<table class=\"form\"> <tr> <td>Note:&nbsp;</td> <th class=\"required"
+"\">highlighted</th> <td>&nbsp;fields are required.</td> </tr> </table>"
+msgstr ""
+"<table class=\"form\"> <tr> <td>ðÒÉÍÅÞÁÎÉÅ:&nbsp;</td><th class=\"required"
+"\">×ÙÄÅÌÅÎÎÙÅ</th><td>&nbsp;ÐÏÌÑ ÄÏÌÖÎÙ ÂÙÔØ ÚÁÐÏÌÎÅÎÙ.</td> </tr> </table>"
+
+#: ../templates/classic/html/issue.item.html:121
+msgid ""
+"Created on <b>${creation}</b> by <b>${creator}</b>, last changed <b>"
+"${activity}</b> by <b>${actor}</b>."
+msgstr ""
+"óÏÚÄÁÎÏ <b>${creation}</b> ÐÏÌØÚÏ×ÁÔÅÌÅÍ <b>${creator}</b>, ÐÏÓÌÅÄÎÅÅ "
+"ÉÚÍÅÎÅÎÉÅ <b>${activity}</b>, ÐÏÌØÚÏ×ÁÔÅÌØ <b>${actor}</b>."
+
+#: ../templates/classic/html/issue.item.html:125
+#: ../templates/classic/html/msg.item.html:56
+msgid "Files"
+msgstr "æÁÊÌÙ"
+
+#: ../templates/classic/html/issue.item.html:127
+#: ../templates/classic/html/msg.item.html:58
+msgid "File name"
+msgstr "éÍÑ ÆÁÊÌÁ"
+
+#: ../templates/classic/html/issue.item.html:128
+#: ../templates/classic/html/msg.item.html:59
+msgid "Uploaded"
+msgstr "úÁÇÒÕÖÅÎ"
+
+#: ../templates/classic/html/issue.item.html:129
+msgid "Type"
+msgstr "ôÉÐ"
+
+#: ../templates/classic/html/issue.item.html:130
+#: ../templates/classic/html/query.edit.html:30
+msgid "Edit"
+msgstr "òÅÄÁËÔÉÒÏ×ÁÔØ"
+
+#: ../templates/classic/html/issue.item.html:131
+msgid "Remove"
+msgstr "õÄÁÌÉÔØ"
+
+#: ../templates/classic/html/issue.item.html:151
+#: ../templates/classic/html/issue.item.html:172
+#: ../templates/classic/html/query.edit.html:50
+msgid "remove"
+msgstr "ÕÄÁÌÉÔØ"
+
+#: ../templates/classic/html/issue.item.html:158
+#: ../templates/classic/html/msg.index.html:9
+msgid "Messages"
+msgstr "óÏÏÂÝÅÎÉÑ"
+
+#: ../templates/classic/html/issue.item.html:162
+msgid "msg${id} (view)"
+msgstr "msg${id} (ÐÒÏÓÍÏÔÒ)"
+
+#: ../templates/classic/html/issue.item.html:163
+msgid "Author: ${author}"
+msgstr "á×ÔÏÒ: ${author}"
+
+#: ../templates/classic/html/issue.item.html:165
+msgid "Date: ${date}"
+msgstr "äÁÔÁ: ${date}"
+
+#: ../templates/classic/html/issue.search.html:2
+msgid "Issue searching - ${tracker}"
+msgstr "ðÏÉÓË - ${tracker}"
+
+#: ../templates/classic/html/issue.search.html:4
+msgid "Issue searching"
+msgstr "ðÏÉÓË"
+
+#: ../templates/classic/html/issue.search.html:25
+msgid "Filter on"
+msgstr "þÔÏ ÉÓËÁÔØ"
+
+#: ../templates/classic/html/issue.search.html:26
+msgid "Display"
+msgstr "ðÏËÁÚÁÔØ"
+
+#: ../templates/classic/html/issue.search.html:27
+msgid "Sort on"
+msgstr "óÏÒÔÉÒÏ×ÁÔØ"
+
+#: ../templates/classic/html/issue.search.html:28
+msgid "Group on"
+msgstr "çÒÕÐÐÉÒÏ×ÁÔØ"
+
+#: ../templates/classic/html/issue.search.html:32
+msgid "All text*:"
+msgstr "×Ï ×ÓÅÍ ÔÅËÓÔÅ*:"
+
+#: ../templates/classic/html/issue.search.html:40
+msgid "Title:"
+msgstr "× ÚÁÇÏÌÏ×ËÅ:"
+
+#: ../templates/classic/html/issue.search.html:50
+msgid "Topic:"
+msgstr "ôÅÍÁ:"
+
+#: ../templates/classic/html/issue.search.html:58
+msgid "ID:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:66
+msgid "Creation Date:"
+msgstr "äÁÔÁ ÓÏÚÄÁÎÉÑ:"
+
+#: ../templates/classic/html/issue.search.html:77
+msgid "Creator:"
+msgstr "á×ÔÏÒ:"
+
+#: ../templates/classic/html/issue.search.html:79
+msgid "created by me"
+msgstr "ÓÏÚÄÁÎÏ ÍÎÏÊ"
+
+#: ../templates/classic/html/issue.search.html:88
+msgid "Activity:"
+msgstr "äÅÊÓÔ×ÉÅ:"
+
+#: ../templates/classic/html/issue.search.html:99
+msgid "Actor:"
+msgstr "÷ÙÐÏÌÎÉÌ:"
+
+#: ../templates/classic/html/issue.search.html:101
+msgid "done by me"
+msgstr "×ÙÐÏÌÎÅÎÏ ÍÎÏÊ"
+
+#: ../templates/classic/html/issue.search.html:112
+msgid "Priority:"
+msgstr "ðÒÉÏÒÉÔÅÔ:"
+
+#: ../templates/classic/html/issue.search.html:114
+#: ../templates/classic/html/issue.search.html:130
+msgid "not selected"
+msgstr "ÎÅ ÕÓÔÁÎÏ×ÌÅÎ"
+
+#: ../templates/classic/html/issue.search.html:125
+msgid "Status:"
+msgstr "óÔÁÔÕÓ:"
+
+#: ../templates/classic/html/issue.search.html:128
+msgid "not resolved"
+msgstr "ÎÅ ÚÁËÒÙÔ"
+
+#: ../templates/classic/html/issue.search.html:143
+msgid "Assigned to:"
+msgstr "éÓÐÏÌÎÉÔÅÌØ:"
+
+#: ../templates/classic/html/issue.search.html:146
+msgid "assigned to me"
+msgstr "ÐÏÒÕÞÅÎÏ ÍÎÅ"
+
+#: ../templates/classic/html/issue.search.html:148
+msgid "unassigned"
+msgstr "ÎÅÎÁÚÎÁÞÅÎÏ"
+
+#: ../templates/classic/html/issue.search.html:158
+msgid "No Sort or group:"
+msgstr "îÅ ÓÏÒÔÉÒÏ×ÁÔØ / ÎÅ ÇÒÕÐÐÉÒÏ×ÁÔØ"
+
+#: ../templates/classic/html/issue.search.html:166
+msgid "Pagesize:"
+msgstr "òÁÚÍÅÒ ÓÔÒÁÎÉÃÙ:"
+
+#: ../templates/classic/html/issue.search.html:172
+msgid "Start With:"
+msgstr "îÁÞÁÔØ Ó:"
+
+#: ../templates/classic/html/issue.search.html:178
+msgid "Sort Descending:"
+msgstr "óÏÒÔÉÒÏ×ÁÔØ ÐÏ ÕÂÙ×ÁÎÉÀ:"
+
+#: ../templates/classic/html/issue.search.html:185
+msgid "Group Descending:"
+msgstr "çÒÕÐÐÉÒÏ×ÁÔØ ÐÏ ÕÂÙ×ÁÎÉÀ"
+
+#: ../templates/classic/html/issue.search.html:192
+msgid "Query name**:"
+msgstr "éÍÑ ÚÁÐÒÏÓÁ**:"
+
+#: ../templates/classic/html/issue.search.html:204
+#: ../templates/classic/html/page.html:31
+#: ../templates/classic/html/page.html:60
+#: ../templates/minimal/html/page.html:31
+msgid "Search"
+msgstr "ðÏÉÓË"
+
+#: ../templates/classic/html/issue.search.html:209
+msgid "*: The \"all text\" field will look in message bodies and issue titles"
+msgstr ""
+"*: ðÏÉÓË ÐÏ ×ÓÅÍÕ ÔÅËÓÔÕ ÉÝÅÔ ××ÅÄÅÎÎÕÀ ÓÔÒÏËÕ × ÚÁÇÏÌÏ×ËÁÈ É × ÔÅÌÅ "
+"ÓÏÏÂÝÅÎÉÊ."
+
+#: ../templates/classic/html/issue.search.html:212
+msgid ""
+"**: If you supply a name, the query will be saved off and available as a "
+"link in the sidebar"
+msgstr ""
+"**: åÓÌÉ ÕËÁÚÁÎÏ ÉÍÑ, ÚÁÐÒÏÓ ÂÕÄÅÔ ÓÏÈÒÁÎÅÎ ÐÏÄ ÜÔÉÍ ÉÍÅÎÅÍ "
+"É ÐÏÑ×ÉÔÓÑ × ÓÐÉÓËÅ ÚÁÐÒÏÓÏ× × ÍÅÎÀ."
+
+#: ../templates/classic/html/keyword.item.html:3
+msgid "Keyword editing - ${tracker}"
+msgstr "òÅÄÁËÔÉÒÏ×ÁÎÉÅ ËÌÀÞÅ×ÙÈ ÓÌÏ× - ${tracker}"
+
+#: ../templates/classic/html/keyword.item.html:5
+msgid "Keyword editing"
+msgstr "òÅÄÁËÔÉÒÏ×ÁÎÉÅ ËÌÀÞÅ×ÙÈ ÓÌÏ×"
+
+#: ../templates/classic/html/keyword.item.html:11
+msgid "Existing Keywords"
+msgstr "óÕÝÅÓÔ×ÕÀÝÉÅ ËÌÀÞÅ×ÙÅ ÓÌÏ×Á"
+
+#: ../templates/classic/html/keyword.item.html:20
+msgid ""
+"To edit an existing keyword (for spelling or typing errors), click on its "
+"entry above."
+msgstr ""
+"äÌÑ ÔÏÇÏ, ÞÔÏÂÙ ÉÓÐÒÁ×ÉÔØ ÏÛÉÂËÉ ÉÌÉ ÏÐÅÞÁÔËÉ × ËÌÀÞÅ×ÏÍ ÓÌÏ×Å, ×ÙÚÏ×ÉÔÅ "
+"ÒÅÄÁËÔÏÒ ÐÏ ÓÓÙÌËÅ × ÜÔÏÍ ÓÐÉÓËÅ."
+
+#: ../templates/classic/html/keyword.item.html:27
+msgid "To create a new keyword, enter it below and click \"Submit New Entry\"."
+msgstr ""
+"þÔÏÂÙ ÓÏÚÄÁÔØ ÎÏ×ÏÅ ËÌÀÞÅ×ÏÅ ÓÌÏ×Ï, ÚÁÐÏÌÎÉÔÅ ÐÏÌÅ ××ÏÄÁ É ÎÁÖÍÉÔÅ ËÎÏÐËÕ "
+"\"äÏÂÁ×ÉÔØ\"."
+
+#: ../templates/classic/html/keyword.item.html:37
+msgid "Keyword"
+msgstr "ëÌÀÞÅ×ÏÅ ÓÌÏ×Ï"
+
+#: ../templates/classic/html/msg.index.html:3
+msgid "List of messages - ${tracker}"
+msgstr "óÐÉÓÏË ÓÏÏÂÝÅÎÉÊ - ${tracker}"
+
+#: ../templates/classic/html/msg.index.html:5
+msgid "Message listing"
+msgstr "óÐÉÓÏË ÓÏÏÂÝÅÎÉÊ"
+
+#: ../templates/classic/html/msg.item.html:6
+msgid "Message ${id} - ${tracker}"
+msgstr "óÏÏÂÝÅÎÉÅ ${id} - ${tracker}"
+
+#: ../templates/classic/html/msg.item.html:9
+msgid "New Message - ${tracker}"
+msgstr "îÏ×ÏÅ ÓÏÏÂÝÅÎÉÅ - ${tracker}"
+
+#: ../templates/classic/html/msg.item.html:13
+msgid "New Message"
+msgstr "îÏ×ÏÅ ÓÏÏÂÝÅÎÉÅ"
+
+#: ../templates/classic/html/msg.item.html:15
+msgid "New Message Editing"
+msgstr "òÅÄÁËÔÉÒÏ×ÁÎÉÅ ÎÏ×ÏÇÏ ÓÏÏÂÝÅÎÉÑ"
+
+#: ../templates/classic/html/msg.item.html:18
+msgid "Message${id}"
+msgstr "óÏÏÂÝÅÎÉÅ ${id}"
+
+#: ../templates/classic/html/msg.item.html:21
+msgid "Message${id} Editing"
+msgstr "òÅÄÁËÔÉÒÏ×ÁÎÉÅ ÓÏÏÂÝÅÎÉÑ ${id}"
+
+#: ../templates/classic/html/msg.item.html:33
+msgid "Author"
+msgstr "á×ÔÏÒ"
+
+#: ../templates/classic/html/msg.item.html:38
+msgid "Recipients"
+msgstr "áÄÒÅÓÁÔÙ"
+
+#: ../templates/classic/html/msg.item.html:49
+msgid "Content"
+msgstr "óÏÄÅÒÖÁÎÉÅ"
+
+#: ../templates/classic/html/page.html:41
+msgid "<b>Your Queries</b> (<a href=\"query?@template=edit\">edit</a>)"
+msgstr "<b>÷ÁÛÉ ÚÁÐÒÏÓÙ</b> (<a href=\"query?@template=edit\">ÒÅÄÁËÔÏÒ</a>)"
+
+#: ../templates/classic/html/page.html:52
+msgid "Issues"
+msgstr "úÁÄÁÞÉ"
+
+#: ../templates/classic/html/page.html:54
+#: ../templates/classic/html/page.html:74
+msgid "Create New"
+msgstr "äÏÂÁ×ÉÔØ"
+
+#: ../templates/classic/html/page.html:56
+msgid "Show Unassigned"
+msgstr "îÅÎÁÚÎÁÞÅÎÎÙÅ"
+
+#: ../templates/classic/html/page.html:58
+msgid "Show All"
+msgstr "ðÏËÁÚÁÔØ ×ÓÅ"
+
+#: ../templates/classic/html/page.html:61
+msgid "Show issue:"
+msgstr "ðÏËÁÚÁÔØ:"
+
+#: ../templates/classic/html/page.html:72
+msgid "Keywords"
+msgstr "ëÌÀÞÅ×ÙÅ&nbsp;ÓÌÏ×Á"
+
+#: ../templates/classic/html/page.html:78
+msgid "Edit Existing"
+msgstr "òÅÄÁËÔÉÒÏ×ÁÔØ"
+
+#: ../templates/classic/html/page.html:84
+#: ../templates/minimal/html/page.html:65
+msgid "Administration"
+msgstr "áÄÍÉÎÉÓÔÒÉÒÏ×ÁÎÉÅ"
+
+#: ../templates/classic/html/page.html:86
+#: ../templates/minimal/html/page.html:66
+msgid "Class List"
+msgstr "óÐÉÓÏË ËÌÁÓÓÏ×"
+
+#: ../templates/classic/html/page.html:90
+#: ../templates/minimal/html/page.html:68
+msgid "User List"
+msgstr "óÐÉÓÏË ÐÏÌØÚÏ×ÁÔÅÌÅÊ"
+
+#: ../templates/classic/html/page.html:92
+#: ../templates/minimal/html/page.html:71
+msgid "Add User"
+msgstr "äÏÂÁ×ÉÔØ ÐÏÌØÚÏ×ÁÔÅÌÑ"
+
+#: ../templates/classic/html/page.html:99
+#: ../templates/classic/html/page.html:105
+#: ../templates/minimal/html/page.html:46
+msgid "Login"
+msgstr "÷ÈÏÄ"
+
+#: ../templates/classic/html/page.html:104
+#: ../templates/minimal/html/page.html:45
+msgid "Remember me?"
+msgstr "úÁÐÏÍÎÉÔØ"
+
+#: ../templates/classic/html/page.html:108
+#: ../templates/classic/html/user.register.html:63
+#: ../templates/minimal/html/page.html:50
+#: ../templates/minimal/html/user.register.html:58
+msgid "Register"
+msgstr "úÁÒÅÇÉÓÔÒÉÒÏ×ÁÔØÓÑ"
+
+#: ../templates/classic/html/page.html:111
+msgid "Lost&nbsp;your&nbsp;login?"
+msgstr "úÁÂÙÌÉ&nbsp;ÐÁÒÏÌØ?"
+
+#: ../templates/classic/html/page.html:116
+msgid "Hello, ${user}"
+msgstr "úÄÒÁ×ÓÔ×ÕÊÔÅ, ${user}!"
+
+#: ../templates/classic/html/page.html:118
+msgid "Your Issues"
+msgstr "úÁÄÁÞÉ"
+
+#: ../templates/classic/html/page.html:119
+#: ../templates/minimal/html/page.html:57
+msgid "Your Details"
+msgstr "õÞÅÔÎÁÑ ËÁÒÔÏÞËÁ"
+
+#: ../templates/classic/html/page.html:121
+#: ../templates/minimal/html/page.html:59
+msgid "Logout"
+msgstr "÷ÙÈÏÄ"
+
+#: ../templates/classic/html/page.html:125
+msgid "Help"
+msgstr "ðÏÍÏÝØ"
+
+#: ../templates/classic/html/page.html:126
+msgid "Roundup docs"
+msgstr "äÏËÕÍÅÎÔÁÃÉÑ Roundup"
+
+#: ../templates/classic/html/page.html:136
+#: ../templates/minimal/html/page.html:81
+msgid "clear this message"
+msgstr "ÓÂÒÏÓÉÔØ ÜÔÏ ÓÏÏÂÝÅÎÉÅ"
+
+#: ../templates/classic/html/page.html:181
+msgid "don't care"
+msgstr "ÎÅ×ÁÖÎÏ"
+
+#: ../templates/classic/html/page.html:183
+msgid "------------"
+msgstr ""
+
+#: ../templates/classic/html/page.html:210
+msgid "no value"
+msgstr "ÎÅÔ ÚÎÁÞÅÎÉÑ"
+
+#: ../templates/classic/html/query.edit.html:4
+msgid "\"Your Queries\" Editing - ${tracker}"
+msgstr "òÅÄÁËÔÉÒÏ×ÁÎÉÅ \"÷ÁÛÉÈ ÚÁÐÒÏÓÏ×\" - ${tracker}"
+
+#: ../templates/classic/html/query.edit.html:6
+msgid "\"Your Queries\" Editing"
+msgstr "òÅÄÁËÔÉÒÏ×ÁÎÉÅ \"÷ÁÛÉÈ ÚÁÐÒÏÓÏ×\""
+
+#: ../templates/classic/html/query.edit.html:11
+msgid "You are not allowed to edit queries."
+msgstr "õ ×ÁÓ ÎÅÔ ÒÁÚÒÅÛÅÎÉÑ ÎÁ ÒÅÄÁËÔÉÒÏ×ÁÎÉÅ ÚÁÐÒÏÓÏ×"
+
+#: ../templates/classic/html/query.edit.html:28
+msgid "Query"
+msgstr "úÁÐÒÏÓ"
+
+#: ../templates/classic/html/query.edit.html:29
+msgid "Include in \"Your Queries\""
+msgstr "÷ËÌÀÞÉÔØ × \"÷ÁÛÉ ÚÁÐÒÏÓÙ\""
+
+#: ../templates/classic/html/query.edit.html:31
+msgid "Private to you?"
+msgstr "ìÉÞÎÙÊ?"
+
+#: ../templates/classic/html/query.edit.html:44
+msgid "leave out"
+msgstr "ÎÅ ×ËÌÀÞÁÔØ"
+
+#: ../templates/classic/html/query.edit.html:45
+msgid "include"
+msgstr "×ËÌÀÞÉÔØ"
+
+#: ../templates/classic/html/query.edit.html:49
+msgid "leave in"
+msgstr "ÏÓÔÁ×ÉÔØ"
+
+#: ../templates/classic/html/query.edit.html:54
+msgid "[query is retired]"
+msgstr "[ÚÁÐÒÏÓ ÕÄÁÌÅÎ]"
+
+#: ../templates/classic/html/query.edit.html:67
+#: ../templates/classic/html/query.edit.html:92
+msgid "edit"
+msgstr "ÒÅÄÁËÔÉÒÏ×ÁÔØ"
+
+#: ../templates/classic/html/query.edit.html:71
+msgid "yes"
+msgstr "ÄÁ"
+
+#: ../templates/classic/html/query.edit.html:73
+msgid "no"
+msgstr "ÎÅÔ"
+
+#: ../templates/classic/html/query.edit.html:79
+msgid "Delete"
+msgstr "õÄÁÌÉÔØ"
+
+#: ../templates/classic/html/query.edit.html:94
+msgid "[not yours to edit]"
+msgstr "[ÞÕÖÏÊ ÚÁÐÒÏÓ - ÒÅÄÁËÔÉÒÏ×ÁÔØ ÎÅÌØÚÑ]"
+
+#: ../templates/classic/html/query.edit.html:102
+msgid "Save Selection"
+msgstr "óÏÈÒÁÎÉÔØ ÉÚÍÅÎÅÎÉÑ"
+
+#: ../templates/classic/html/user.forgotten.html:3
+msgid "Password reset request - ${tracker}"
+msgstr "óÂÒÏÓ ÐÁÒÏÌÑ - ${tracker}"
+
+#: ../templates/classic/html/user.forgotten.html:5
+msgid "Password reset request"
+msgstr "óÂÒÏÓ ÐÁÒÏÌÑ"
+
+#: ../templates/classic/html/user.forgotten.html:9
+msgid ""
+"You have two options if you have forgotten your password. If you know the "
+"email address you registered with, enter it below."
+msgstr ""
+"åÓÌÉ ×Ù ÚÁÂÙÌÉ ÐÁÒÏÌØ, Õ ×ÁÓ ÅÓÔØ Ä×Å ×ÏÚÍÏÖÎÏÓÔÉ. åÓÌÉ ×Ù ÐÏÍÎÉÔÅ ÁÄÒÅÓ "
+"ÜÌÅËÔÒÏÎÎÏÊ ÐÏÞÔÙ, Ó ËÏÔÏÒÙÍ ×Ù ÚÁÒÅÇÉÓÔÒÉÒÏ×ÁÎÙ, ××ÅÄÉÔÅ ÅÇÏ × ÜÔÏÍ ÐÏÌÅ."
+
+#: ../templates/classic/html/user.forgotten.html:16
+msgid "Email Address:"
+msgstr "áÄÒÅÓ email:"
+
+#: ../templates/classic/html/user.forgotten.html:24
+#: ../templates/classic/html/user.forgotten.html:34
+msgid "Request password reset"
+msgstr "ïÞÉÓÔÉÔØ ÐÁÒÏÌØ"
+
+#: ../templates/classic/html/user.forgotten.html:30
+msgid "Or, if you know your username, then enter it below."
+msgstr "éÌÉ, ÅÓÌÉ ×Ù ÐÏÍÎÉÔÅ ×ÁÛÅ ÉÍÑ ÐÏÌØÚÏ×ÁÔÅÌÑ, ÕËÁÖÉÔÅ ÅÇÏ ÚÄÅÓØ"
+
+#: ../templates/classic/html/user.forgotten.html:33
+msgid "Username:"
+msgstr "éÍÑ ÐÏÌØÚÏ×ÁÔÅÌÑ:"
+
+#: ../templates/classic/html/user.forgotten.html:39
+msgid ""
+"A confirmation email will be sent to you - please follow the instructions "
+"within it to complete the reset process."
+msgstr ""
+"äÌÑ ÐÏÄÔ×ÅÒÖÄÅÎÉÑ ÜÔÏÊ ÏÐÅÒÁÃÉÉ ×ÁÍ ÂÕÄÅÔ ÐÏÓÌÁÎÏ ÓÏÏÂÝÅÎÉÅ ÐÏ ÜÌÅËÔÒÏÎÎÏÊ "
+"ÐÏÞÔÅ. ÷ ÜÔÏÍ ÐÉÓØÍÅ ÂÕÄÅÔ ÎÁÐÉÓÁÎÏ, ÞÔÏ ×Ù ÄÏÌÖÎÙ ÓÄÅÌÁÔØ, ÞÔÏÂÙ ÏÞÉÓÔÉÔØ "
+"ÐÁÒÏÌØ Roundup."
+
+#: ../templates/classic/html/user.index.html:3
+#: ../templates/minimal/html/user.index.html:3
+msgid "User listing - ${tracker}"
+msgstr "óÐÉÓÏË ÐÏÌØÚÏ×ÁÔÅÌÅÊ - ${tracker}"
+
+#: ../templates/classic/html/user.index.html:5
+#: ../templates/minimal/html/user.index.html:5
+msgid "User listing"
+msgstr "óÐÉÓÏË ÐÏÌØÚÏ×ÁÔÅÌÅÊ"
+
+#: ../templates/classic/html/user.index.html:14
+#: ../templates/minimal/html/user.index.html:14
+msgid "Username"
+msgstr "ðÏÌØÚÏ×ÁÔÅÌØ"
+
+#: ../templates/classic/html/user.index.html:15
+msgid "Real name"
+msgstr "éÍÑ, ÆÁÍÉÌÉÑ"
+
+#: ../templates/classic/html/user.index.html:16
+#: ../templates/classic/html/user.item.html:70
+#: ../templates/classic/html/user.register.html:45
+msgid "Organisation"
+msgstr "ïÒÇÁÎÉÚÁÃÉÑ"
+
+#: ../templates/classic/html/user.index.html:17
+#: ../templates/minimal/html/user.index.html:15
+msgid "Email address"
+msgstr "áÄÒÅÓ email"
+
+#: ../templates/classic/html/user.index.html:18
+msgid "Phone number"
+msgstr "ôÅÌÅÆÏÎ"
+
+#: ../templates/classic/html/user.index.html:19
+msgid "Retire"
+msgstr "õ×ÏÌÉÔØ"
+
+#: ../templates/classic/html/user.index.html:32
+msgid "retire"
+msgstr "Õ×ÏÌÉÔØ"
+
+#: ../templates/classic/html/user.item.html:7
+#: ../templates/minimal/html/user.item.html:7
+msgid "User ${id}: ${title} - ${tracker}"
+msgstr "ðÏÌØÚÏ×ÁÔÅÌØ ${id}: ${title} - ${tracker}"
+
+#: ../templates/classic/html/user.item.html:10
+#: ../templates/minimal/html/user.item.html:10
+msgid "New User - ${tracker}"
+msgstr "îÏ×ÙÊ ÐÏÌØÚÏ×ÁÔÅÌØ - ${tracker}"
+
+#: ../templates/classic/html/user.item.html:14
+#: ../templates/minimal/html/user.item.html:14
+msgid "New User"
+msgstr "îÏ×ÙÊ ÐÏÌØÚÏ×ÁÔÅÌØ"
+
+#: ../templates/classic/html/user.item.html:16
+#: ../templates/minimal/html/user.item.html:16
+msgid "New User Editing"
+msgstr "òÅÇÉÓÔÒÁÃÉÑ ÎÏ×ÏÇÏ ÐÏÌØÚÏ×ÁÔÅÌÑ"
+
+#: ../templates/classic/html/user.item.html:19
+#: ../templates/minimal/html/user.item.html:19
+msgid "User${id}"
+msgstr "ðÏÌØÚÏ×ÁÔÅÌØ ${id}"
+
+#: ../templates/classic/html/user.item.html:22
+#: ../templates/minimal/html/user.item.html:22
+msgid "User${id} Editing"
+msgstr "òÅÄÁËÔÉÒÏ×ÁÎÉÅ ËÁÒÔÏÞËÉ ÐÏÌØÚÏ×ÁÔÅÌÑ ${id}"
+
+#: ../templates/classic/html/user.item.html:43
+#: ../templates/classic/html/user.register.html:21
+#: ../templates/minimal/html/user.item.html:40
+#: ../templates/minimal/html/user.register.html:26
+msgid "Login Name"
+msgstr "õÞÅÔÎÏÅ ÉÍÑ"
+
+#: ../templates/classic/html/user.item.html:47
+#: ../templates/classic/html/user.register.html:25
+#: ../templates/minimal/html/user.item.html:44
+#: ../templates/minimal/html/user.register.html:30
+msgid "Login Password"
+msgstr "ðÁÒÏÌØ"
+
+#: ../templates/classic/html/user.item.html:51
+#: ../templates/classic/html/user.register.html:29
+#: ../templates/minimal/html/user.item.html:48
+#: ../templates/minimal/html/user.register.html:34
+msgid "Confirm Password"
+msgstr "(ÅÝÅ ÒÁÚ)"
+
+#: ../templates/classic/html/user.item.html:55
+#: ../templates/classic/html/user.register.html:33
+#: ../templates/minimal/html/user.item.html:52
+#: ../templates/minimal/html/user.register.html:38
+msgid "Roles"
+msgstr "òÏÌÉ"
+
+#: ../templates/classic/html/user.item.html:61
+#: ../templates/minimal/html/user.item.html:58
+msgid "(to give the user more than one role, enter a comma,separated,list)"
+msgstr "(ÅÓÌÉ ÒÏÌÅÊ ÎÅÓËÏÌØËÏ, ÐÅÒÅÞÉÓÌÉÔÅ ÉÈ ÞÅÒÅÚ ÚÁÐÑÔÕÀ)"
+
+#: ../templates/classic/html/user.item.html:66
+#: ../templates/classic/html/user.register.html:41
+msgid "Phone"
+msgstr "ôÅÌÅÆÏÎ"
+
+#: ../templates/classic/html/user.item.html:74
+msgid "Timezone"
+msgstr "þÁÓÏ×ÏÊ ÐÏÑÓ"
+
+#: ../templates/classic/html/user.item.html:78
+msgid "(this is a numeric hour offset, the default is ${zone})"
+msgstr ""
+"(ÞÉÓÌÏ - ÒÁÚÎÉÃÁ ÍÅÖÄÕ ÍÅÓÔÎÙÍ É ÇÒÉÎ×ÉÞÓËÉÍ ×ÒÅÍÅÎÅÍ, ÐÏ ÕÍÏÌÞÁÎÉÀ - "
+"${zone})"
+
+#: ../templates/classic/html/user.item.html:83
+#: ../templates/classic/html/user.register.html:49
+#: ../templates/minimal/html/user.item.html:63
+#: ../templates/minimal/html/user.register.html:46
+msgid "E-mail address"
+msgstr "áÄÒÅÓ email"
+
+#: ../templates/classic/html/user.item.html:91
+#: ../templates/classic/html/user.register.html:53
+#: ../templates/minimal/html/user.item.html:71
+#: ../templates/minimal/html/user.register.html:50
+msgid "Alternate E-mail addresses<br>One address per line"
+msgstr "äÏÐÏÌÎÉÔÅÌØÎÙÅ ÁÄÒÅÓÁ email<br />ðÏ ÏÄÎÏÍÕ ÁÄÒÅÓÕ × ÓÔÒÏËÅ"
+
+#: ../templates/classic/html/user.register.html:4
+#: ../templates/classic/html/user.register.html:7
+#: ../templates/minimal/html/user.register.html:4
+#: ../templates/minimal/html/user.register.html:7
+msgid "Registering with ${tracker}"
+msgstr "òÅÇÉÓÔÒÁÃÉÑ × ${tracker}"
+
+#: ../templates/classic/html/user.rego_progress.html:4
+#: ../templates/minimal/html/user.rego_progress.html:4
+msgid "Registration in progress - ${tracker}"
+msgstr "÷ÙÐÏÌÎÑÅÔÓÑ ÒÅÇÉÓÔÒÁÃÉÑ - ${tracker}"
+
+#: ../templates/classic/html/user.rego_progress.html:6
+#: ../templates/minimal/html/user.rego_progress.html:6
+msgid "Registration in progress..."
+msgstr "÷ÙÐÏÌÎÑÅÔÓÑ ÒÅÇÉÓÔÒÁÃÉÑ..."
+
+#: ../templates/classic/html/user.rego_progress.html:10
+#: ../templates/minimal/html/user.rego_progress.html:10
+msgid ""
+"You will shortly receive an email to confirm your registration. To complete "
+"the registration process, visit the link indicated in the email."
+msgstr ""
+"óËÏÒÏ ×Ù ÐÏÌÕÞÉÔÅ ÐÉÓØÍÏ Ó ÐÏÄÔ×ÅÒÖÄÅÎÉÅÍ ×ÁÛÅÊ ÒÅÇÉÓÔÒÁÃÉÉ. äÌÑ ÔÏÇÏ, ÞÔÏÂÙ "
+"ÚÁËÏÎÞÉÔØ ÒÅÇÉÓÔÒÁÃÉÀ, ×ÙÚÏ×ÉÔÅ ÕËÁÚÁÎÎÕÀ × ÐÉÓØÍÅ ÓÓÙÌËÕ."
+
+#: ../templates/minimal/html/home.html:2
+msgid "Tracker home - ${tracker}"
+msgstr "ãÅÎÔÒ ÕÐÒÁ×ÌÅÎÉÑ ÚÁÄÁÎÉÑÍÉ - ${tracker}"
+
+#: ../templates/minimal/html/home.html:4
+msgid "Tracker home"
+msgstr "ãÅÎÔÒ ÕÐÒÁ×ÌÅÎÉÑ ÚÁÄÁÎÉÑÍÉ"
+
+#: ../templates/minimal/html/home.html:16
+msgid "Please select from one of the menu options on the left."
+msgstr "÷ÙÂÅÒÉÔÅ ÄÅÊÓÔ×ÉÅ ÉÚ ÍÅÎÀ ÓÌÅ×Á."
+
+#: ../templates/minimal/html/home.html:19
+msgid "Please log in or register."
+msgstr "ðÏÖÁÌÕÊÓÔÁ, ×ÈÏÄÉÔÅ ÉÌÉ ÚÁÒÅÇÉÓÔÒÉÒÕÊÔÅÓØ"
+
+#: ../templates/minimal/html/page.html:55
+msgid "Hello,<br>${user}"
+msgstr "ðÒÉ×ÅÔ,<br>${user}"

Added: tracker/vendor/roundup/current/locale/zh_CN.po
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/locale/zh_CN.po	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,2743 @@
+# Chinese message file for Roundup Issue Tracker
+# limodou <limodou at gmail.com>
+#
+# $Id: zh_CN.po,v 1.3 2005/05/16 09:23:22 a1s Exp $
+#
+# roundup.pot revision 1.10
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: 0.8.3\n"
+"Report-Msgid-Bugs-To: roundup-devel at lists.sourceforge.net\n"
+"POT-Creation-Date: 2004-10-19 12:33+0300\n"
+"PO-Revision-Date: 2005-05-16 13:56+0800\n"
+"Last-Translator: limodou <limodou at gmail.com>\n"
+"Language-Team: Chinese Simplified <limodou at gmail.com>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=1; plural=0;\n"
+"X-Poedit-Language: Chinese\n"
+"X-Poedit-Country: CHINA\n"
+
+# ../roundup/admin.py:84 :943 :992 :1014
+#: ../roundup/admin.py:84
+#: ../roundup/admin.py:943
+#: ../roundup/admin.py:992
+#: ../roundup/admin.py:1014
+#, python-format
+msgid "no such class \"%(classname)s\""
+msgstr "无此类别 \"%(classname)s\""
+
+# ../roundup/admin.py:94 :98
+#: ../roundup/admin.py:94
+#: ../roundup/admin.py:98
+#, python-format
+msgid "argument \"%(arg)s\" not propname=value"
+msgstr "参数 \"%(arg)s\" 不是 propname=value 的形式"
+
+#: ../roundup/admin.py:111
+#, python-format
+msgid ""
+"Problem: %(message)s\n"
+"\n"
+msgstr ""
+"问题: %(message)s\n"
+"\n"
+
+#: ../roundup/admin.py:112
+#, python-format
+msgid ""
+"%(message)sUsage: roundup-admin [options] [<command> <arguments>]\n"
+"\n"
+"Options:\n"
+" -i instance home  -- specify the issue tracker \"home directory\" to administer\n"
+" -u                -- the user[:password] to use for commands\n"
+" -d                -- print full designators not just class id numbers\n"
+" -c                -- when outputting lists of data, comma-separate them.\n"
+"                      Same as '-S \",\"'.\n"
+" -S <string>       -- when outputting lists of data, string-separate them\n"
+" -s                -- when outputting lists of data, space-separate them.\n"
+"                      Same as '-S \" \"'.\n"
+"\n"
+" Only one of -s, -c or -S can be specified.\n"
+"\n"
+"Help:\n"
+" roundup-admin -h\n"
+" roundup-admin help                       -- this help\n"
+" roundup-admin help <command>             -- command-specific help\n"
+" roundup-admin help all                   -- all available help\n"
+msgstr ""
+"%(message)s用法: roundup-admin [options] [<command> <arguments>]\n"
+"\n"
+"选项:\n"
+" -i 实例路径       -- 指定问题跟踪系统 \"根目录\" 为 管理员\n"
+" -u                -- user[:password] 用于命令中\n"
+" -d                -- 打印所有的指示信息而不只是类的ID号\n"
+" -c                -- 在输出数据列表时,使用句号('.')分隔。\n"
+"                      如同执行 '-S \",\"'。\n"
+" -S <string>       -- 当输出数据列表时,使用 string 分隔\n"
+" -s                -- 当输出数据列表时,使用空格分隔。\n"
+"                      如同执行 '-S \" \"'。\n"
+"\n"
+" -s, -c 或者 -S 只能有一个被指定。\n"
+"\n"
+"帮助:\n"
+" roundup-admin -h\n"
+" roundup-admin help                       -- 本帮助\n"
+" roundup-admin help <command>             -- 命令详解帮助\n"
+" roundup-admin help all                   -- 所有可用的帮助\n"
+
+#: ../roundup/admin.py:137
+msgid "Commands:"
+msgstr "命令:"
+
+#: ../roundup/admin.py:144
+msgid ""
+"Commands may be abbreviated as long as the abbreviation\n"
+"matches only one command, e.g. l == li == lis == list."
+msgstr ""
+"命令可以被缩写,只要缩写只有一个命令可以匹配上,\n"
+"如:l == li == lis == list."
+
+#: ../roundup/admin.py:174
+msgid ""
+"\n"
+"All commands (except help) require a tracker specifier. This is just\n"
+"the path to the roundup tracker you're working with. A roundup tracker\n"
+"is where roundup keeps the database and configuration file that defines\n"
+"an issue tracker. It may be thought of as the issue tracker's \"home\n"
+"directory\". It may be specified in the environment variable TRACKER_HOME\n"
+"or on the command line as \"-i tracker\".\n"
+"\n"
+"A designator is a classname and a nodeid concatenated, eg. bug1, user10, ...\n"
+"\n"
+"Property values are represented as strings in command arguments and in the\n"
+"printed results:\n"
+" . Strings are, well, strings.\n"
+" . Date values are printed in the full date format in the local time zone,\n"
+"   and accepted in the full format or any of the partial formats explained\n"
+"   below.\n"
+" . Link values are printed as node designators. When given as an argument,\n"
+"   node designators and key strings are both accepted.\n"
+" . Multilink values are printed as lists of node designators joined\n"
+"   by commas.  When given as an argument, node designators and key\n"
+"   strings are both accepted; an empty string, a single node, or a list\n"
+"   of nodes joined by commas is accepted.\n"
+"\n"
+"When property values must contain spaces, just surround the value with\n"
+"quotes, either ' or \". A single space may also be backslash-quoted. If a\n"
+"value must contain a quote character, it must be backslash-quoted or inside\n"
+"quotes. Examples:\n"
+"           hello world      (2 tokens: hello, world)\n"
+"           \"hello world\"    (1 token: hello world)\n"
+"           \"Roch'e\" Compaan (2 tokens: Roch'e Compaan)\n"
+"           Roch\\'e Compaan  (2 tokens: Roch'e Compaan)\n"
+"           address=\"1 2 3\"  (1 token: address=1 2 3)\n"
+"           \\\\               (1 token: \\)\n"
+"           \\n"
+"\\r\\t           (1 token: a newline, carriage-return and tab)\n"
+"\n"
+"When multiple nodes are specified to the roundup get or roundup set\n"
+"commands, the specified properties are retrieved or set on all the listed\n"
+"nodes.\n"
+"\n"
+"When multiple results are returned by the roundup get or roundup find\n"
+"commands, they are printed one per line (default) or joined by commas (with\n"
+"the -c) option.\n"
+"\n"
+"Where the command changes data, a login name/password is required. The\n"
+"login may be specified as either \"name\" or \"name:password\".\n"
+" . ROUNDUP_LOGIN environment variable\n"
+" . the -u command-line option\n"
+"If either the name or password is not supplied, they are obtained from the\n"
+"command-line.\n"
+"\n"
+"Date format examples:\n"
+"  \"2000-04-17.03:45\" means <Date 2000-04-17.08:45:00>\n"
+"  \"2000-04-17\" means <Date 2000-04-17.00:00:00>\n"
+"  \"01-25\" means <Date yyyy-01-25.00:00:00>\n"
+"  \"08-13.22:13\" means <Date yyyy-08-14.03:13:00>\n"
+"  \"11-07.09:32:43\" means <Date yyyy-11-07.14:32:43>\n"
+"  \"14:25\" means <Date yyyy-mm-dd.19:25:00>\n"
+"  \"8:47:11\" means <Date yyyy-mm-dd.13:47:11>\n"
+"  \".\" means \"right now\"\n"
+"\n"
+"Command help:\n"
+msgstr ""
+"\n"
+"所有的命令(除了 help)要求指定一个tracker。这就是你正在工作的tracker的路径。\n"
+"一个tracker就是roundup维护的数据库和定义了tracker配置文件的地方。可以把它想\n"
+"象为问题跟踪系统的\"起始\"目录。它可以在环境变量 TRACKER_HOME 或在命令行以 \n"
+"\"-i tracker\" 来指定。\n"
+"\n"
+"一个指示器(designator)是一个类名和一个结点id的结合体,如:bug1, user10, ...\n"
+"\n"
+"属性值在命令参数中和打印结果中被描述为字符串:\n"
+" . Strings 表示字符串。\n"
+" . Date 的值在本地时区中按全日期格式打印,并且可以按全日期格式或下面解释的任\n"
+"   何部分日期格式来接收。\n"
+" . Link 的值按结点指示器(designator)来打印。当作为参数给出时,结点指示器\n"
+"   (designator)和键字符串都可以接收。\n"
+" . Multilink 的值按结点指示器(designator)列表(以逗号分隔)来打印。当作为一个参\n"
+"   数给出时,结点指示器(designator)或以逗号联接的结点列表都是可以接受的。\n"
+"\n"
+"当属性值必须包含空格时,只需使用 ' 或者 \" 来包含值。单个空格也可以用反斜线来\n"
+"转义。如果一个值必须包含引号字符,它必须使用反斜线来转义或内部包含。例如:\n"
+"           hello world      (2 tokens: hello, world)\n"
+"           \"hello world\"    (1 token: hello world)\n"
+"           \"Roch'e\" Compaan (2 tokens: Roch'e Compaan)\n"
+"           Roch\\'e Compaan  (2 tokens: Roch'e Compaan)\n"
+"           address=\"1 2 3\"  (1 token: address=1 2 3)\n"
+"           \\\\               (1 token: \\)\n"
+"           \\n"
+"\\r\\t           (1 token: a newline, carriage-return and tab)\n"
+"\n"
+"当多个结点被指定用在 Roundup 的 get 或 set 命令时,指定的属性在所有列出\n"
+"的结点上会被获取或设置。\n"
+"\n"
+"当 Roundup 的 get 或 find 命令返回多个结果时,每行将打印一个属性(缺省)或\n"
+"用逗号联接起来(用 -c 参数)。\n"
+"\n"
+"在存在修改数据的命令中,需要登录名/口令。登录名或者用 \"name\" 或 \"name:password\"\n"
+"来指定。\n"
+" . ROUNDUP_LOGIN 环境变量\n"
+" . -u 命令行选项\n"
+"如果名字或口令都没有提供,它们将从命令行获得。\n"
+"\n"
+"Date format examples:\n"
+"  \"2000-04-17.03:45\" 表示 <Date 2000-04-17.08:45:00>\n"
+"  \"2000-04-17\" 表示 <Date 2000-04-17.00:00:00>\n"
+"  \"01-25\" 表示 <Date yyyy-01-25.00:00:00>\n"
+"  \"08-13.22:13\" 表示 <Date yyyy-08-14.03:13:00>\n"
+"  \"11-07.09:32:43\" 表示 <Date yyyy-11-07.14:32:43>\n"
+"  \"14:25\" 表示 <Date yyyy-mm-dd.19:25:00>\n"
+"  \"8:47:11\" 表示 <Date yyyy-mm-dd.13:47:11>\n"
+"  \".\" 表示 \"现在\"\n"
+"\n"
+"使用帮助:\n"
+
+#: ../roundup/admin.py:237
+#, python-format
+msgid "%s:"
+msgstr ""
+
+#: ../roundup/admin.py:242
+msgid ""
+"Usage: help topic\n"
+"        Give help about topic.\n"
+"\n"
+"        commands  -- list commands\n"
+"        <command> -- help specific to a command\n"
+"        initopts  -- init command options\n"
+"        all       -- all available help\n"
+"        "
+msgstr ""
+"用法:help topic\n"
+"        给出关于主题的帮助。\n"
+"\n"
+"        commands  -- 列出命令\n"
+"        <command> -- 指定命令的帮助规范\n"
+"        initopts  -- 初始化命令选项\n"
+"        all       -- 所有可用的帮助\n"
+"        "
+
+#: ../roundup/admin.py:265
+#, python-format
+msgid "Sorry, no help for \"%(topic)s\""
+msgstr "抱歉,没有对 \"%(topic)s\" 的帮助信息"
+
+# ../roundup/admin.py:337 :387
+#: ../roundup/admin.py:337
+#: ../roundup/admin.py:387
+msgid "Templates:"
+msgstr "模板:"
+
+# ../roundup/admin.py:340 :398
+#: ../roundup/admin.py:340
+#: ../roundup/admin.py:398
+msgid "Back ends:"
+msgstr "后端:"
+
+#: ../roundup/admin.py:343
+msgid ""
+"Usage: install [template [backend [admin password]]]\n"
+"        Install a new Roundup tracker.\n"
+"\n"
+"        The command will prompt for the tracker home directory\n"
+"        (if not supplied through TRACKER_HOME or the -i option).\n"
+"        The template, backend and admin password may be specified\n"
+"        on the command-line as arguments, in that order.\n"
+"\n"
+"        The initialise command must be called after this command in order\n"
+"        to initialise the tracker's database. You may edit the tracker's\n"
+"        initial database contents before running that command by editing\n"
+"        the tracker's dbinit.py module init() function.\n"
+"\n"
+"        See also initopts help.\n"
+"        "
+msgstr ""
+"用法:install [template [backend [admin password]]]\n"
+"        安装一个新的tracker实例。\n"
+"\n"
+"        这个命令将提示输入 tracker 起始目录\n"
+"        (如果没有通过 TRACKER_HOME 或 -i 选项提供)。\n"
+"        模板、后端和管理员口令应该在命令行按顺序以参数的形式被指定。\n"
+"\n"
+"        初始化(initialise)命令必须在这个命令之后被调用,以便初始化tracker数\n"
+"        据库。你可以在运行初始化命令之前编辑 tracker 的 dbinit.py 模块的\n"
+"        init() 方法来修改 tracker 的初始数据库内容。\n"
+"\n"
+"        请查看初始化参数帮助。\n"
+"        "
+
+# ../roundup/admin.py:359 :494 :573 :623 :676 :697 :725 :796 :863 :934 :982
+# :1004 :1031 :1093 :1159
+#: ../roundup/admin.py:359
+#: ../roundup/admin.py:494
+#: ../roundup/admin.py:573
+#: ../roundup/admin.py:623
+#: ../roundup/admin.py:676
+#: ../roundup/admin.py:697
+#: ../roundup/admin.py:725
+#: ../roundup/admin.py:796
+#: ../roundup/admin.py:863
+#: ../roundup/admin.py:934
+#: ../roundup/admin.py:982
+#: ../roundup/admin.py:1004
+#: ../roundup/admin.py:1031
+#: ../roundup/admin.py:1093
+#: ../roundup/admin.py:1159
+msgid "Not enough arguments supplied"
+msgstr "未提供足够的参数"
+
+#: ../roundup/admin.py:365
+#, python-format
+msgid "Instance home parent directory \"%(parent)s\" does not exist"
+msgstr "实例目录的父目录 \"%(parent)s\" 不存在"
+
+#: ../roundup/admin.py:374
+#, python-format
+msgid ""
+"WARNING: There appears to be a tracker in \"%(tracker_home)s\"!\n"
+"If you re-install it, you will lose all the data!\n"
+"Erase it? Y/N: "
+msgstr ""
+"警告:在 \"%(tracker_home)s\" 已经存在一个tracker了!\n"
+"如果你打算重新安装它,所有的数据将会丢失!\n"
+"删除它吗?Y/N: "
+
+#: ../roundup/admin.py:389
+msgid "Select template [classic]: "
+msgstr "选择模板 [classic]:"
+
+#: ../roundup/admin.py:400
+msgid "Select backend [anydbm]: "
+msgstr "选择后端 [anydbm]:"
+
+#: ../roundup/admin.py:409
+#, python-format
+msgid ""
+"\n"
+" You should now edit the tracker configuration file:\n"
+"   %(config_file)s"
+msgstr ""
+"\n"
+" 现在你应该修改tracker的配置文件:\n"
+"   %(config_file)s"
+
+#: ../roundup/admin.py:418
+msgid " ... at a minimum, you must set following options:"
+msgstr " ... 至少,你必须设置以下选项:"
+
+#: ../roundup/admin.py:423
+#, python-format
+msgid ""
+"\n"
+" If you wish to modify the database schema,\n"
+" you should also edit the schema file:\n"
+"   %(database_config_file)s\n"
+" You may also change the database initialisation file:\n"
+"   %(database_init_file)s\n"
+" ... see the documentation on customizing for more information.\n"
+msgstr ""
+"\n"
+" 如果你想要修改数据库结构,\n"
+" 你也需要编辑表结构文件:\n"
+"   %(database_config_file)s\n"
+" 你可能也需要修改数据库初始化文件:\n"
+"   %(database_init_file)s\n"
+" ... 查看关于客户化的文档来了解更多的信息。\n"
+
+#. password
+#: ../roundup/admin.py:438
+msgid ""
+"Usage: initialise [adminpw]\n"
+"        Initialise a new Roundup tracker.\n"
+"\n"
+"        The administrator details will be set at this step.\n"
+"\n"
+"        Execute the tracker's initialisation function dbinit.init()\n"
+"        "
+msgstr ""
+"用法:initialise [adminpw]\n"
+"        初始化一个新的tracker。\n"
+"\n"
+"        管理员的信息需要在这一步进行设置。\n"
+"\n"
+"        执行tracker的初始化函数 dbinit.init()\n"
+"        "
+
+#: ../roundup/admin.py:452
+msgid "Admin Password: "
+msgstr "管理员口令:"
+
+#: ../roundup/admin.py:453
+msgid "       Confirm: "
+msgstr "       确认:"
+
+#: ../roundup/admin.py:457
+msgid "Instance home does not exist"
+msgstr "实例目录不存在"
+
+#: ../roundup/admin.py:461
+msgid "Instance has not been installed"
+msgstr "实例还没有安装"
+
+#: ../roundup/admin.py:466
+msgid ""
+"WARNING: The database is already initialised!\n"
+"If you re-initialise it, you will lose all the data!\n"
+"Erase it? Y/N: "
+msgstr ""
+"警告:数据库已经被初始化!\n"
+"如果你重新初始化它,所有的数据将会丢失!\n"
+"删除它吗?Y/N: "
+
+#: ../roundup/admin.py:487
+msgid ""
+"Usage: get property designator[,designator]*\n"
+"        Get the given property of one or more designator(s).\n"
+"\n"
+"        Retrieves the property value of the nodes specified\n"
+"        by the designators.\n"
+"        "
+msgstr ""
+"用法:get property designator[,designator]*\n"
+"        得到指定属性一个或多个指示器(designator)。\n"
+"\n"
+"        通过指示器(designator)来得到指定结点的属性值。\n"
+"        "
+
+# ../roundup/admin.py:527 :542
+#: ../roundup/admin.py:527
+#: ../roundup/admin.py:542
+#, python-format
+msgid "property %s is not of type Multilink or Link so -d flag does not apply."
+msgstr "属性 %s 不是 Multilink 或 Link 类型,所以 -d 标志不能应用。"
+
+# ../roundup/admin.py:550 :945 :994 :1016
+#: ../roundup/admin.py:550
+#: ../roundup/admin.py:945
+#: ../roundup/admin.py:994
+#: ../roundup/admin.py:1016
+#, python-format
+msgid "no such %(classname)s node \"%(nodeid)s\""
+msgstr "没有这样的 %(classname)s 结点 \"%(nodeid)s\""
+
+#: ../roundup/admin.py:552
+#, python-format
+msgid "no such %(classname)s property \"%(propname)s\""
+msgstr "没有这样的 %(classname)s 属性 \"%(propname)s\""
+
+#: ../roundup/admin.py:561
+msgid ""
+"Usage: set items property=value property=value ...\n"
+"        Set the given properties of one or more items(s).\n"
+"\n"
+"        The items are specified as a class or as a comma-separated\n"
+"        list of item designators (ie \"designator[,designator,...]\").\n"
+"\n"
+"        This command sets the properties to the values for all designators\n"
+"        given. If the value is missing (ie. \"property=\") then the property\n"
+"        is un-set. If the property is a multilink, you specify the linked\n"
+"        ids for the multilink as comma-separated numbers (ie \"1,2,3\").\n"
+"        "
+msgstr ""
+"用法:set items property=value property=value ...\n"
+"        设置一个或多个条目的属性。\n"
+"\n"
+"        条目指的是一个类别,或以逗号分隔的项目指示器(designator)列表(例如:\"designator[,designator,...]\")。\n"
+"\n"
+"        这个命令为所有给出的指示器(designator)设置属性值。如果属性值被省略\n"
+"        (例如:\"property=\")那么属性是未设置的。如果属性是一个多链接(multilink),\n"
+"        你需要为多链接提供用逗号分隔的数字(例如 \"1,2,3\")。\n"
+"        "
+
+#: ../roundup/admin.py:615
+msgid ""
+"Usage: find classname propname=value ...\n"
+"        Find the nodes of the given class with a given link property value.\n"
+"\n"
+"        Find the nodes of the given class with a given link property value.\n"
+"        The value may be either the nodeid of the linked node, or its key\n"
+"        value.\n"
+"        "
+msgstr ""
+"用法:find classname propname=value ...\n"
+"        根据给定的 link 属性值来查找给定类型的结点。\n"
+"\n"
+"        根据给定的 link 属性值来查找给定类型的结点。这个值或者是链接结点的结点ID,\n"
+"        或者是结点的键值。\n"
+"        "
+
+# ../roundup/admin.py:663 :816 :828 :882
+#: ../roundup/admin.py:663
+#: ../roundup/admin.py:816
+#: ../roundup/admin.py:828
+#: ../roundup/admin.py:882
+#, python-format
+msgid "%(classname)s has no property \"%(propname)s\""
+msgstr "%(classname)s 没有 \"%(propname)s\" 属性"
+
+#: ../roundup/admin.py:670
+msgid ""
+"Usage: specification classname\n"
+"        Show the properties for a classname.\n"
+"\n"
+"        This lists the properties for a given class.\n"
+"        "
+msgstr ""
+"用法: specification classname\n"
+"        显示一个类型名的属性。\n"
+"\n"
+"        会列出给定类型的属性。\n"
+"        "
+
+#: ../roundup/admin.py:685
+#, python-format
+msgid "%(key)s: %(value)s (key property)"
+msgstr "%(key)s: %(value)s (关键属性)"
+
+#: ../roundup/admin.py:687
+#, python-format
+msgid "%(key)s: %(value)s"
+msgstr ""
+
+#: ../roundup/admin.py:690
+msgid ""
+"Usage: display designator[,designator]*\n"
+"        Show the property values for the given node(s).\n"
+"\n"
+"        This lists the properties and their associated values for the given\n"
+"        node.\n"
+"        "
+msgstr ""
+"用法:display designator[,designator]*\n"
+"        显示给出结点的属性值。\n"
+"\n"
+"        将显示给出结点的属性和相应的值。\n"
+"        "
+
+#: ../roundup/admin.py:714
+#, python-format
+msgid "%(key)s: %(value)r"
+msgstr ""
+
+#: ../roundup/admin.py:717
+msgid ""
+"Usage: create classname property=value ...\n"
+"        Create a new entry of a given class.\n"
+"\n"
+"        This creates a new entry of the given class using the property\n"
+"        name=value arguments provided on the command line after the \"create\"\n"
+"        command.\n"
+"        "
+msgstr ""
+"用法:create classname property=value ...\n"
+"        创建一个给定类的新记录。\n"
+"\n"
+"        创建一个给定类的新记录,将使用 \"create\" 命令行后面的属性 name=value 参数。\n"
+"        "
+
+#: ../roundup/admin.py:744
+#, python-format
+msgid "%(propname)s (Password): "
+msgstr "%(propname)s (口令):"
+
+#: ../roundup/admin.py:746
+#, python-format
+msgid "   %(propname)s (Again): "
+msgstr "   %(propname)s (再次):"
+
+#: ../roundup/admin.py:748
+msgid "Sorry, try again..."
+msgstr "抱歉,再试一次..."
+
+#: ../roundup/admin.py:752
+#, python-format
+msgid "%(propname)s (%(proptype)s): "
+msgstr ""
+
+#: ../roundup/admin.py:770
+#, python-format
+msgid "you must provide the \"%(propname)s\" property."
+msgstr "你必须提供 \"%(propname)s\" 属性。"
+
+#: ../roundup/admin.py:781
+msgid ""
+"Usage: list classname [property]\n"
+"        List the instances of a class.\n"
+"\n"
+"        Lists all instances of the given class. If the property is not\n"
+"        specified, the  \"label\" property is used. The label property is\n"
+"        tried in order: the key, \"name\", \"title\" and then the first\n"
+"        property, alphabetically.\n"
+"\n"
+"        With -c, -S or -s print a list of item id's if no property\n"
+"        specified.  If property specified, print list of that property\n"
+"        for every class instance.\n"
+"        "
+msgstr ""
+"用法:list classname [property]\n"
+"        列出类型的实例。\n"
+"\n"
+"        列出所有给定类型的实例。如果属性未被指定,则使用 \"label\" 属性。\n"
+"        label 属性以下列顺序进行尝试:键、\"name\"、\"title\" 和按字典顺序\n"
+"        的第一个属性。\n"
+"\n"
+"        如果没有指定属性,使用 -c, -S 或 -s 会打印出条目 id 的列表。如果指\n"
+"        定了属性,对每个类型实例会打印出这个属性。\n"
+"        "
+
+#: ../roundup/admin.py:794
+msgid "Too many arguments supplied"
+msgstr "提供了太多的参数了"
+
+#: ../roundup/admin.py:830
+#, python-format
+msgid "%(nodeid)4s: %(value)s"
+msgstr ""
+
+#: ../roundup/admin.py:834
+msgid ""
+"Usage: table classname [property[,property]*]\n"
+"        List the instances of a class in tabular form.\n"
+"\n"
+"        Lists all instances of the given class. If the properties are not\n"
+"        specified, all properties are displayed. By default, the column\n"
+"        widths are the width of the largest value. The width may be\n"
+"        explicitly defined by defining the property as \"name:width\".\n"
+"        For example::\n"
+"\n"
+"          roundup> table priority id,name:10\n"
+"          Id Name\n"
+"          1  fatal-bug\n"
+"          2  bug\n"
+"          3  usability\n"
+"          4  feature\n"
+"\n"
+"        Also to make the width of the column the width of the label,\n"
+"        leave a trailing : without a width on the property. For example::\n"
+"\n"
+"          roundup> table priority id,name:\n"
+"          Id Name\n"
+"          1  fata\n"
+"          2  bug\n"
+"          3  usab\n"
+"          4  feat\n"
+"\n"
+"        will result in a the 4 character wide \"Name\" column.\n"
+"        "
+msgstr ""
+"用法:table classname [property[,property]*]\n"
+"        以表格的表式列出类型的实例。\n"
+"\n"
+"        列出给定类型的所有实例。如果没有指定属性,所有属性都会显示出来。\n"
+"        缺省情况下,列的宽度是最大值的宽度。这个宽度通过定义属性为 \"name:width\"\n"
+"        被显示地定义。例如:\n"
+"\n"
+"          roundup> table priority id,name:10\n"
+"          Id Name\n"
+"          1  fatal-bug\n"
+"          2  bug\n"
+"          3  usability\n"
+"          4  feature\n"
+"\n"
+"        也可以让列的宽度为标签的宽度,在属性上没有宽度值。例如:\n"
+"\n"
+"          roundup> table priority id,name:\n"
+"          Id Name\n"
+"          1  fata\n"
+"          2  bug\n"
+"          3  usab\n"
+"          4  feat\n"
+"\n"
+"        将生成4个字符宽的 \"Name\" 列。\n"
+"        "
+
+#: ../roundup/admin.py:878
+#, python-format
+msgid "\"%(spec)s\" not name:width"
+msgstr "\"%(spec)s\" 不是 名字:宽度"
+
+#: ../roundup/admin.py:928
+msgid ""
+"Usage: history designator\n"
+"        Show the history entries of a designator.\n"
+"\n"
+"        Lists the journal entries for the node identified by the designator.\n"
+"        "
+msgstr ""
+"用法:history designator\n"
+"        显示指示器(designator)的历史记录。\n"
+"\n"
+"        显示由指示器(designator)指明的结点的日志记录。\n"
+"        "
+
+#: ../roundup/admin.py:949
+msgid ""
+"Usage: commit\n"
+"        Commit changes made to the database during an interactive session.\n"
+"\n"
+"        The changes made during an interactive session are not\n"
+"        automatically written to the database - they must be committed\n"
+"        using this command.\n"
+"\n"
+"        One-off commands on the command-line are automatically committed if\n"
+"        they are successful.\n"
+"        "
+msgstr ""
+"用法:commit\n"
+"        提交在一个交互会话中所产生的改动。\n"
+"\n"
+"        在一个交互会话中所产生的改动不会自动写入数据库 - 它们必须使用此命令\n"
+"        来提交。\n"
+"        在命令行中的 One-off 命令如果成功会被自动提交。\n"
+"        "
+
+#: ../roundup/admin.py:963
+msgid ""
+"Usage: rollback\n"
+"        Undo all changes that are pending commit to the database.\n"
+"\n"
+"        The changes made during an interactive session are not\n"
+"        automatically written to the database - they must be committed\n"
+"        manually. This command undoes all those changes, so a commit\n"
+"        immediately after would make no changes to the database.\n"
+"        "
+msgstr ""
+"用法:rollback\n"
+"        撤销所有未提交到数据库的改动。\n"
+"\n"
+"        在交互对话中产生的改动并不自动写到数据库中 - 它们必须被手工提交。\n"
+"        这个命令用来撤销所有这些改动,所以在后面跟上提交的话不会对数据库\n"
+"        产生变化。\n"
+"        "
+
+#: ../roundup/admin.py:975
+msgid ""
+"Usage: retire designator[,designator]*\n"
+"        Retire the node specified by designator.\n"
+"\n"
+"        This action indicates that a particular node is not to be retrieved\n"
+"        by the list or find commands, and its key value may be re-used.\n"
+"        "
+msgstr ""
+"用法:retire designator[,designator]*\n"
+"        回收由指示器(designator)所指明的结点。\n"
+"\n"
+"        这个动作指明一个特别的结点将不能被 list 或 find 命令得到,并且\n"
+"        它的键值可以被重用。\n"
+"        "
+
+#: ../roundup/admin.py:998
+msgid ""
+"Usage: restore designator[,designator]*\n"
+"        Restore the retired node specified by designator.\n"
+"\n"
+"        The given nodes will become available for users again.\n"
+"        "
+msgstr ""
+"Usage: restore designator[,designator]*\n"
+"        恢复由指示器(designator)表明的已经回收的结点。\n"
+"\n"
+"        给定的结点将对用户来说再次生效。\n"
+"        "
+
+#. grab the directory to export to
+#: ../roundup/admin.py:1020
+msgid ""
+"Usage: export [class[,class]] export_dir\n"
+"        Export the database to colon-separated-value files.\n"
+"\n"
+"        Optionally limit the export to just the names classes.\n"
+"\n"
+"        This action exports the current data from the database into\n"
+"        colon-separated-value files that are placed in the nominated\n"
+"        destination directory.\n"
+"        "
+msgstr ""
+"用法:export [class[,class]] export_dir\n"
+"        导出数据库为冒号分隔值的文件。\n"
+"\n"
+"        对于导出的可选限制只是类名。\n"
+"\n"
+"        这个动作从数据库中导出当前的数据到以冒号分隔值的文件中去,它们将存\n"
+"        放在指定的目标目录中。\n"
+"        "
+
+#: ../roundup/admin.py:1073
+msgid ""
+"Usage: import import_dir\n"
+"        Import a database from the directory containing CSV files,\n"
+"        two per class to import.\n"
+"\n"
+"        The files used in the import are:\n"
+"\n"
+"        <class>.csv\n"
+"          This must define the same properties as the class (including\n"
+"          having a \"header\" line with those property names.)\n"
+"        <class>-journals.csv\n"
+"          This defines the journals for the items being imported.\n"
+"\n"
+"        The imported nodes will have the same nodeid as defined in the\n"
+"        import file, thus replacing any existing content.\n"
+"\n"
+"        The new nodes are added to the existing database - if you want to\n"
+"        create a new database using the imported data, then create a new\n"
+"        database (or, tediously, retire all the old data.)\n"
+"        "
+msgstr ""
+"用法:import import_dir\n"
+"        从包含 CSV 文件的目录中导入数据库,一个类有两个文件用于导入。\n"
+"\n"
+"        用于导入的文件为:\n"
+"\n"
+"        <class>.csv\n"
+"          它必须定义与类型一样的属性(包括一个 \"header\" 行包含那些\n"
+"          属性的名字。)\n"
+"        <class>-journals.csv\n"
+"          它用来定义被导入的条目的日志。\n"
+"\n"
+"        被导入的结点将具与在导入文件中一样的结点id,以便可以替换任何\n"
+"        任何已经存在的内容。\n"
+"        新结点被加入到已经存在的数据库中 - 如果你想要使用导入数据来创\n"
+"        建一个新的数据库,那么创建一个新数据库(或者,麻烦点,回收所有\n"
+"        旧数据。)\n"
+"        "
+
+#: ../roundup/admin.py:1141
+msgid ""
+"Usage: pack period | date\n"
+"\n"
+"        Remove journal entries older than a period of time specified or\n"
+"        before a certain date.\n"
+"\n"
+"        A period is specified using the suffixes \"y\", \"m\", and \"d\". The\n"
+"        suffix \"w\" (for \"week\") means 7 days.\n"
+"\n"
+"              \"3y\" means three years\n"
+"              \"2y 1m\" means two years and one month\n"
+"              \"1m 25d\" means one month and 25 days\n"
+"              \"2w 3d\" means two weeks and three days\n"
+"\n"
+"        Date format is \"YYYY-MM-DD\" eg:\n"
+"            2001-01-01\n"
+"\n"
+"        "
+msgstr ""
+"用法:pack period | date\n"
+"\n"
+"        删除早于指定的时期或日期的旧的流水记录。\n"
+"\n"
+"        一个时期使用后缀 \"y\", \"m\", 和 \"d\"。后缀 \"w\"(表示 \"week\")\n"
+"        表示 7 天。\n"
+"\n"
+"              \"3y\" 表示3年\n"
+"              \"2y 1m\" 表示2年1个月\n"
+"              \"1m 25d\" 表示1月25天\n"
+"              \"2w 3d\" 表示2周3天\n"
+"\n"
+"        日期格式是 \"YYYY-MM-DD\" 例如:\n"
+"            2001-01-01\n"
+"\n"
+"        "
+
+#: ../roundup/admin.py:1169
+msgid "Invalid format"
+msgstr "无效的格式"
+
+#: ../roundup/admin.py:1179
+msgid ""
+"Usage: reindex [classname|designator]*\n"
+"        Re-generate a tracker's search indexes.\n"
+"\n"
+"        This will re-generate the search indexes for a tracker.\n"
+"        This will typically happen automatically.\n"
+"        "
+msgstr ""
+"用法:reindex [classname|designator]*\n"
+"        重新生成 tracker 的搜索索引。\n"
+"\n"
+"        重新生成 tracker 的搜索索引,它将自动进行。\n"
+"        "
+
+#: ../roundup/admin.py:1193
+#, python-format
+msgid "no such item \"%(designator)s\""
+msgstr "没有这样的条目 \"%(designator)s\""
+
+#: ../roundup/admin.py:1203
+msgid ""
+"Usage: security [Role name]\n"
+"        Display the Permissions available to one or all Roles.\n"
+"        "
+msgstr ""
+"用法:security [角色名]\n"
+"        显示一个或多个角色的权限。\n"
+"        "
+
+#: ../roundup/admin.py:1211
+#, python-format
+msgid "No such Role \"%(role)s\""
+msgstr "没有这样的角色 \"%(role)s\""
+
+#: ../roundup/admin.py:1217
+#, python-format
+msgid "New Web users get the Roles \"%(role)s\""
+msgstr "新Web用户得到角色 \"%(role)s\""
+
+#: ../roundup/admin.py:1219
+#, python-format
+msgid "New Web users get the Role \"%(role)s\""
+msgstr "新Web用户得到角色 \"%(role)s\""
+
+#: ../roundup/admin.py:1222
+#, python-format
+msgid "New Email users get the Roles \"%(role)s\""
+msgstr "新邮件用户得到角色 \"%(role)s\""
+
+#: ../roundup/admin.py:1224
+#, python-format
+msgid "New Email users get the Role \"%(role)s\""
+msgstr "新邮件用户得到角色 \"%(role)s\""
+
+#: ../roundup/admin.py:1227
+#, python-format
+msgid "Role \"%(name)s\":"
+msgstr "角色 \"%(name)s\":"
+
+#: ../roundup/admin.py:1230
+#, python-format
+msgid " %(description)s (%(name)s for \"%(klass)s\" only)"
+msgstr " %(description)s (%(name)s 仅用于 \"%(klass)s\")"
+
+#: ../roundup/admin.py:1233
+#, python-format
+msgid " %(description)s (%(name)s)"
+msgstr ""
+
+#: ../roundup/admin.py:1259
+#, python-format
+msgid "Unknown command \"%(command)s\" (\"help commands\" for a list)"
+msgstr "未知命令 \"%(command)s\" (\"help commands\" 查看命令列表)"
+
+#: ../roundup/admin.py:1265
+#, python-format
+msgid "Multiple commands match \"%(command)s\": %(list)s"
+msgstr "多命令匹配 \"%(command)s\": %(list)s"
+
+#: ../roundup/admin.py:1272
+msgid "Enter tracker home: "
+msgstr "输入tracker起始目录:"
+
+# ../roundup/admin.py:1279 :1285 :1305
+#: ../roundup/admin.py:1279
+#: ../roundup/admin.py:1285
+#: ../roundup/admin.py:1305
+#, python-format
+msgid "Error: %(message)s"
+msgstr "错误:%(message)s"
+
+#: ../roundup/admin.py:1293
+#, python-format
+msgid "Error: Couldn't open tracker: %(message)s"
+msgstr "错误:不能打开tracker:%(message)s"
+
+#: ../roundup/admin.py:1318
+#, python-format
+msgid ""
+"Roundup %s ready for input.\n"
+"Type \"help\" for help."
+msgstr ""
+"Roundup %s 输入就绪。\n"
+"敲入 \"help\" 获得帮助。"
+
+#: ../roundup/admin.py:1323
+msgid "Note: command history and editing not available"
+msgstr "注意:命令历史和编辑无效"
+
+#: ../roundup/admin.py:1327
+msgid "roundup> "
+msgstr ""
+
+#: ../roundup/admin.py:1329
+msgid "exit..."
+msgstr "退出..."
+
+#: ../roundup/admin.py:1339
+msgid "There are unsaved changes. Commit them (y/N)? "
+msgstr "存在未被保存的改动。提交吗(y/N)?"
+
+#: ../roundup/backends/rdbms_common.py:1420
+msgid "create"
+msgstr "创建"
+
+#: ../roundup/backends/rdbms_common.py:1583
+msgid "unlink"
+msgstr "解除"
+
+#: ../roundup/backends/rdbms_common.py:1587
+msgid "link"
+msgstr "链接"
+
+#: ../roundup/backends/rdbms_common.py:1696
+msgid "set"
+msgstr "设置"
+
+#: ../roundup/backends/rdbms_common.py:1720
+msgid "retired"
+msgstr "收回"
+
+#: ../roundup/backends/rdbms_common.py:1750
+msgid "restored"
+msgstr "恢复"
+
+#: ../roundup/cgi/actions.py:53
+#, python-format
+msgid "You do not have permission to %(action)s the %(classname)s class."
+msgstr "你没有权限来 %(action)s %(classname)s 类型。"
+
+#: ../roundup/cgi/actions.py:81
+msgid "No type specified"
+msgstr "没有指定类型"
+
+#: ../roundup/cgi/actions.py:83
+msgid "No ID entered"
+msgstr "没有输入ID"
+
+#: ../roundup/cgi/actions.py:89
+#, python-format
+msgid "\"%(input)s\" is not an ID (%(classname)s ID required)"
+msgstr "\"%(input)s\" 不是一个 ID (要求 %(classname)s ID)"
+
+#: ../roundup/cgi/actions.py:109
+msgid "You may not retire the admin or anonymous user"
+msgstr "你不能删除管理员或匿名用户"
+
+#: ../roundup/cgi/actions.py:116
+#, python-format
+msgid "%(classname)s %(itemid)s has been retired"
+msgstr "%(classname)s %(itemid)s 已经被回收了"
+
+#: ../roundup/cgi/actions.py:271
+#, python-format
+msgid "Not enough values on line %(line)s"
+msgstr "在 %(line)s 行没有足够的值"
+
+#: ../roundup/cgi/actions.py:318
+msgid "Items edited OK"
+msgstr "项目编辑成功"
+
+#: ../roundup/cgi/actions.py:377
+#, python-format
+msgid "%(class)s %(id)s %(properties)s edited ok"
+msgstr "%(class)s %(id)s %(properties)s 编辑成功"
+
+#: ../roundup/cgi/actions.py:380
+#, python-format
+msgid "%(class)s %(id)s - nothing changed"
+msgstr "%(class)s %(id)s - 没有改动"
+
+#: ../roundup/cgi/actions.py:392
+#, python-format
+msgid "%(class)s %(id)s created"
+msgstr "%(class)s %(id)s 被创建"
+
+#: ../roundup/cgi/actions.py:424
+#, python-format
+msgid "You do not have permission to edit %(class)s"
+msgstr "你没有权限来编辑 %(class)s"
+
+#: ../roundup/cgi/actions.py:436
+#, python-format
+msgid "You do not have permission to create %(class)s"
+msgstr "你没有权限来创建 %(class)s"
+
+#: ../roundup/cgi/actions.py:459
+msgid "You do not have permission to edit user roles"
+msgstr "你没有编辑用户或角色的权限"
+
+#: ../roundup/cgi/actions.py:518
+#, python-format
+msgid "Edit Error: %s"
+msgstr "编辑错误:%s"
+
+# ../roundup/cgi/actions.py:549 :559 :730 :749
+#: ../roundup/cgi/actions.py:549
+#: ../roundup/cgi/actions.py:559
+#: ../roundup/cgi/actions.py:730
+#: ../roundup/cgi/actions.py:749
+#, python-format
+msgid "Error: %s"
+msgstr "错误:%s"
+
+#: ../roundup/cgi/actions.py:585
+msgid ""
+"Invalid One Time Key!\n"
+"(a Mozilla bug may cause this message to show up erroneously, please check your email)"
+msgstr ""
+"Invalid One Time Key!\n"
+"(一个 Mozilla 的错误可能会错误地引发这个消息,你检查你的邮件)"
+
+#: ../roundup/cgi/actions.py:627
+#, python-format
+msgid "Password reset and email sent to %s"
+msgstr "口令被重设,邮件被发给 %s"
+
+#: ../roundup/cgi/actions.py:636
+msgid "Unknown username"
+msgstr "未知用户名"
+
+#: ../roundup/cgi/actions.py:644
+msgid "Unknown email address"
+msgstr "未知邮件地址"
+
+#: ../roundup/cgi/actions.py:649
+msgid "You need to specify a username or address"
+msgstr "你需要指定用户名或地址"
+
+#: ../roundup/cgi/actions.py:674
+#, python-format
+msgid "Email sent to %s"
+msgstr "邮件发给 %s"
+
+#: ../roundup/cgi/actions.py:693
+msgid "You are now registered, welcome!"
+msgstr "你已经注册,欢迎!"
+
+#: ../roundup/cgi/actions.py:738
+msgid "It is not permitted to supply roles at registration."
+msgstr "不允许在注册时指供角色。"
+
+#: ../roundup/cgi/actions.py:820
+msgid "You are logged out"
+msgstr "你已经注销"
+
+#: ../roundup/cgi/actions.py:831
+msgid "Username required"
+msgstr "需要用户名"
+
+#: ../roundup/cgi/actions.py:846
+msgid "Ivalid login"
+msgstr "无效登录"
+
+#: ../roundup/cgi/actions.py:853
+msgid "Invalid login"
+msgstr "无效登录"
+
+#: ../roundup/cgi/actions.py:861
+msgid "You do not have permission to login"
+msgstr "你没有登录的权限"
+
+#: ../roundup/cgi/cgitb.py:49
+#, python-format
+msgid ""
+"<h1>Templating Error</h1>\n"
+"<p><b>%(exc_type)s</b>: %(exc_value)s</p>\n"
+"<p class=\"help\">Debugging information follows</p>"
+msgstr ""
+"<h1>模板错误</h1>\n"
+"<p><b>%(exc_type)s</b>: %(exc_value)s</p>\n"
+"<p class=\"help\">调试信息为</p>"
+
+#: ../roundup/cgi/cgitb.py:64
+#, python-format
+msgid "<li>\"%(name)s\" (%(info)s)</li>"
+msgstr ""
+
+#: ../roundup/cgi/cgitb.py:67
+#, python-format
+msgid "<li>Looking for \"%(name)s\", current path:<ol>%(path)s</ol></li>"
+msgstr "<li>查找 \"%(name)s\", 当前路径:<ol>%(path)s</ol></li>"
+
+#: ../roundup/cgi/cgitb.py:71
+#, python-format
+msgid "<li>In %s</li>"
+msgstr "<li>在 %s</li>"
+
+#: ../roundup/cgi/cgitb.py:76
+#, python-format
+msgid "A problem occurred in your template \"%s\"."
+msgstr "在你的模板 \"%s\" 中发生一个问题。"
+
+#: ../roundup/cgi/cgitb.py:84
+#, python-format
+msgid ""
+"\n"
+"<li>While evaluating the %(info)r expression on line %(line)d\n"
+"<table class=\"otherinfo\" style=\"font-size: 90%%\">\n"
+" <tr><th colspan=\"2\" class=\"header\">Current variables:</th></tr>\n"
+" %(globals)s\n"
+" %(locals)s\n"
+"</table></li>\n"
+msgstr ""
+"\n"
+"<li>在 %(line)d 行计算 %(info)r 表达式\n"
+"<table class=\"otherinfo\" style=\"font-size: 90%%\">\n"
+" <tr><th colspan=\"2\" class=\"header\">当前变量:</th></tr>\n"
+" %(globals)s\n"
+" %(locals)s\n"
+"</table></li>\n"
+
+#: ../roundup/cgi/cgitb.py:103
+msgid "Full traceback:"
+msgstr "完整跟踪信息:"
+
+#: ../roundup/cgi/cgitb.py:116
+#, python-format
+msgid "<font size=+1><strong>%(exc_type)s</strong>: %(exc_value)s</font>"
+msgstr ""
+
+#: ../roundup/cgi/cgitb.py:120
+msgid "<p>A problem occurred while running a Python script. Here is the sequence of function calls leading up to the error, with the most recent (innermost) call first. The exception attributes are:"
+msgstr "<p>在运行 Python 脚本时发生了一个错误。这是导致出错的一系列的函数调用,最近的(最里层的)调用在前。异常属性是:"
+
+#: ../roundup/cgi/cgitb.py:129
+msgid "&lt;file is None - probably inside <tt>eval</tt> or <tt>exec</tt>&gt;"
+msgstr "&lt;文件为 None - 可能在 <tt>eval</tt> 或者 <tt>exec</tt>&gt;"
+
+#: ../roundup/cgi/cgitb.py:138
+#, python-format
+msgid "in <strong>%s</strong>"
+msgstr "在 <strong>%s</strong>"
+
+# ../roundup/cgi/cgitb.py:172 :178
+#: ../roundup/cgi/cgitb.py:172
+#: ../roundup/cgi/cgitb.py:178
+msgid "<em>undefined</em>"
+msgstr "<em>未定义</em>"
+
+#: ../roundup/cgi/client.py:273
+msgid "Form Error: "
+msgstr "表格错误:"
+
+#: ../roundup/cgi/client.py:323
+#, python-format
+msgid "Unrecognized charset: %r"
+msgstr "无法识别的字符集:%r"
+
+#: ../roundup/cgi/client.py:398
+msgid "Anonymous users are not allowed to use the web interface"
+msgstr "匿名用户不允许使用web界面"
+
+#: ../roundup/cgi/form_parser.py:283
+#, python-format
+msgid "link \"%(key)s\" value \"%(value)s\" not a designator"
+msgstr "链接 \"%(key)s\" 的值 \"%(value)s\" 不是一个 指示器(designator)"
+
+#: ../roundup/cgi/form_parser.py:290
+#, python-format
+msgid "%(class)s %(property)s is not a link or multilink property"
+msgstr "%(class)s %(property)s 不是一个 Link 或 MultiLink 属性"
+
+#: ../roundup/cgi/form_parser.py:312
+#, python-format
+msgid "You have submitted a %(action)s action for the property \"%(property)s\" which doesn't exist"
+msgstr "你提交了一个对于不存在属性 \"%(property)s\" 的一个操作 %(action)s"
+
+# ../roundup/cgi/form_parser.py:331 :357
+#: ../roundup/cgi/form_parser.py:331
+#: ../roundup/cgi/form_parser.py:357
+#, python-format
+msgid "You have submitted more than one value for the %s property"
+msgstr "你需要提交针对 %s 属性的一个以上的值"
+
+# ../roundup/cgi/form_parser.py:354 :360
+#: ../roundup/cgi/form_parser.py:354
+#: ../roundup/cgi/form_parser.py:360
+msgid "Password and confirmation text do not match"
+msgstr "口令和确认文本不匹配"
+
+#: ../roundup/cgi/form_parser.py:395
+#, python-format
+msgid "property \"%(propname)s\": \"%(value)s\" not currently in list"
+msgstr "属性 \"%(propname)s\": \"%(value)s\" 当前不在列表中"
+
+#: ../roundup/cgi/form_parser.py:509
+#, python-format
+msgid "Required %(class)s property %(property)s not supplied"
+msgstr "要求的 %(class)s 属性 %(property)s 没有被提供"
+
+#: ../roundup/cgi/form_parser.py:529
+msgid "File is empty"
+msgstr "文件为空"
+
+#: ../roundup/cgi/templating.py:68
+#, python-format
+msgid "You are not allowed to %(action)s items of class %(class)s"
+msgstr "你不允许 %(action)s 类别 %(class)s 的项目"
+
+#: ../roundup/cgi/templating.py:598
+msgid "(list)"
+msgstr "(列表)"
+
+#: ../roundup/cgi/templating.py:632
+msgid "Submit New Entry"
+msgstr "提交新的项"
+
+#: ../roundup/cgi/templating.py:644
+msgid "New node - no history"
+msgstr "新记录 - 无历史"
+
+#: ../roundup/cgi/templating.py:744
+msgid "Submit Changes"
+msgstr "提交变动"
+
+#: ../roundup/cgi/templating.py:825
+msgid "<em>The indicated property no longer exists</em>"
+msgstr "<em>指示的属性不再存在</em>"
+
+#: ../roundup/cgi/templating.py:826
+#, python-format
+msgid "<em>%s: %s</em>\n"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:839
+#, python-format
+msgid "The linked class %(classname)s no longer exists"
+msgstr "链接的类别 %(classname)s 不再存在"
+
+# ../roundup/cgi/templating.py:872 :893
+#: ../roundup/cgi/templating.py:872
+#: ../roundup/cgi/templating.py:893
+msgid "<strike>The linked node no longer exists</strike>"
+msgstr "<strike>链接的结点不再存在</strike>"
+
+#: ../roundup/cgi/templating.py:932
+msgid "No"
+msgstr "否"
+
+#: ../roundup/cgi/templating.py:932
+msgid "Yes"
+msgstr "是"
+
+#: ../roundup/cgi/templating.py:943
+#, python-format
+msgid "%s: (no value)"
+msgstr "%s: (无值)"
+
+#: ../roundup/cgi/templating.py:955
+msgid "<strong><em>This event is not handled by the history display!</em></strong>"
+msgstr "<strong><em>这个事件不能被历史显示所处理!</em></strong>"
+
+#: ../roundup/cgi/templating.py:967
+msgid "<tr><td colspan=4><strong>Note:</strong></td></tr>"
+msgstr "<tr><td colspan=4><strong>注意:</strong></td></tr>"
+
+#: ../roundup/cgi/templating.py:976
+msgid "History"
+msgstr "历史"
+
+#: ../roundup/cgi/templating.py:978
+msgid "<th>Date</th>"
+msgstr "<th>日期</th>"
+
+#: ../roundup/cgi/templating.py:979
+msgid "<th>User</th>"
+msgstr "<th>用户</th>"
+
+#: ../roundup/cgi/templating.py:980
+msgid "<th>Action</th>"
+msgstr "<th>动作</th>"
+
+#: ../roundup/cgi/templating.py:981
+msgid "<th>Args</th>"
+msgstr "<th>参数</th>"
+
+#: ../roundup/cgi/templating.py:1221
+msgid "*encrypted*"
+msgstr "*加密的*"
+
+#: ../roundup/cgi/templating.py:1386
+msgid "default value for DateHTMLProperty must be either DateHTMLProperty or string date representation."
+msgstr "DateHTMLProperty 的缺省值或者是 DateHTMLProperty 或字符串的日期表示。"
+
+#: ../roundup/cgi/templating.py:1571
+#, python-format
+msgid "<option %svalue=\"-1\">- no selection -</option>"
+msgstr "<option %svalue=\"-1\">- 未选择 -</option>"
+
+#: ../roundup/date.py:180
+#, python-format
+msgid "Not a date spec: %s"
+msgstr "不是日期格式:%s"
+
+#: ../roundup/date.py:231
+#, python-format
+msgid "%r not a date spec (%s)"
+msgstr "%r 不是日期格式 (%s)"
+
+#: ../roundup/date.py:522
+msgid "Not an interval spec: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS] [date spec]"
+msgstr "不是时间间隔规范:[+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS] [日期规范]"
+
+#: ../roundup/date.py:541
+msgid "Not an interval spec: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS]"
+msgstr "不是时间间隔规范:[+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS]"
+
+#: ../roundup/date.py:678
+#, python-format
+msgid "%(number)s year"
+msgstr "%(number)så¹´"
+
+#: ../roundup/date.py:682
+#, python-format
+msgid "%(number)s month"
+msgstr "%(number)s月"
+
+#: ../roundup/date.py:686
+#, python-format
+msgid "%(number)s week"
+msgstr "%(number)s周"
+
+#: ../roundup/date.py:690
+#, python-format
+msgid "%(number)s day"
+msgstr "%(number)s天"
+
+#: ../roundup/date.py:694
+msgid "tomorrow"
+msgstr "明天"
+
+#: ../roundup/date.py:696
+msgid "yesterday"
+msgstr "昨天"
+
+#: ../roundup/date.py:699
+#, python-format
+msgid "%(number)s hour"
+msgstr "%(number)s小时"
+
+#: ../roundup/date.py:703
+msgid "an hour"
+msgstr "1小时"
+
+#: ../roundup/date.py:705
+msgid "1 1/2 hours"
+msgstr "1个半小时"
+
+#: ../roundup/date.py:707
+#, python-format
+msgid "1 %(number)s/4 hours"
+msgstr "1 %(number)s/4 小时"
+
+#: ../roundup/date.py:711
+msgid "in a moment"
+msgstr "一会儿"
+
+#: ../roundup/date.py:713
+msgid "just now"
+msgstr "刚才"
+
+#: ../roundup/date.py:716
+msgid "1 minute"
+msgstr "1分钟"
+
+#: ../roundup/date.py:719
+#, python-format
+msgid "%(number)s minute"
+msgstr "%(number)s分钟"
+
+#: ../roundup/date.py:722
+msgid "1/2 an hour"
+msgstr "半小时"
+
+#: ../roundup/date.py:724
+#, python-format
+msgid "%(number)s/4 hour"
+msgstr "%(number)s/4 小时"
+
+#: ../roundup/date.py:728
+#, python-format
+msgid "%s ago"
+msgstr "%s 之前"
+
+#: ../roundup/date.py:730
+#, python-format
+msgid "in %s"
+msgstr "在 %s"
+
+#: ../roundup/roundupdb.py:130
+msgid "files"
+msgstr "文件"
+
+#: ../roundup/roundupdb.py:130
+msgid "messages"
+msgstr "信息"
+
+#: ../roundup/roundupdb.py:130
+msgid "nosy"
+msgstr "杂事"
+
+#: ../roundup/roundupdb.py:130
+msgid "superseder"
+msgstr "延期"
+
+#: ../roundup/roundupdb.py:130
+msgid "title"
+msgstr "标题"
+
+#: ../roundup/roundupdb.py:131
+msgid "assignedto"
+msgstr "分配给"
+
+#: ../roundup/roundupdb.py:131
+msgid "priority"
+msgstr "优先级"
+
+#: ../roundup/roundupdb.py:131
+msgid "status"
+msgstr "状态"
+
+#: ../roundup/roundupdb.py:131
+msgid "topic"
+msgstr "主题"
+
+#: ../roundup/roundupdb.py:134
+msgid "activity"
+msgstr "活跃度"
+
+#. following properties are common for all hyperdb classes
+#. they are listed here to keep things in one place
+#: ../roundup/roundupdb.py:134
+msgid "actor"
+msgstr "执行人"
+
+#: ../roundup/roundupdb.py:134
+msgid "creation"
+msgstr "创建"
+
+#: ../roundup/roundupdb.py:134
+msgid "creator"
+msgstr "创建者"
+
+#: ../roundup/scripts/roundup_demo.py:32
+#, python-format
+msgid "Enter directory path to create demo tracker [%s]: "
+msgstr "输入目录来创建演示tracker [%s]:"
+
+#: ../roundup/scripts/roundup_mailgw.py:36
+#, python-format
+msgid ""
+"Usage: %(program)s [-v] [-c] [[-C class] -S field=value]* <instance home> [method]\n"
+"\n"
+"Options:\n"
+" -v: print version and exit\n"
+" -c: default class of item to create (else the tracker's MAIL_DEFAULT_CLASS)\n"
+" -C / -S: see below\n"
+"\n"
+"The roundup mail gateway may be called in one of four ways:\n"
+" . with an instance home as the only argument,\n"
+" . with both an instance home and a mail spool file,\n"
+" . with both an instance home and a POP/APOP server account, or\n"
+" . with both an instance home and a IMAP/IMAPS server account.\n"
+"\n"
+"It also supports optional -C and -S arguments that allows you to set a\n"
+"fields for a class created by the roundup-mailgw. The default class if\n"
+"not specified is msg, but the other classes: issue, file, user can\n"
+"also be used. The -S or --set options uses the same\n"
+"property=value[;property=value] notation accepted by the command line\n"
+"roundup command or the commands that can be given on the Subject line\n"
+"of an email message.\n"
+"\n"
+"It can let you set the type of the message on a per email address basis.\n"
+"\n"
+"PIPE:\n"
+" In the first case, the mail gateway reads a single message from the\n"
+" standard input and submits the message to the roundup.mailgw module.\n"
+"\n"
+"UNIX mailbox:\n"
+" In the second case, the gateway reads all messages from the mail spool\n"
+" file and submits each in turn to the roundup.mailgw module. The file is\n"
+" emptied once all messages have been successfully handled. The file is\n"
+" specified as:\n"
+"   mailbox /path/to/mailbox\n"
+"\n"
+"POP:\n"
+" In the third case, the gateway reads all messages from the POP server\n"
+" specified and submits each in turn to the roundup.mailgw module. The\n"
+" server is specified as:\n"
+"    pop username:password at server\n"
+" The username and password may be omitted:\n"
+"    pop username at server\n"
+"    pop server\n"
+" are both valid. The username and/or password will be prompted for if\n"
+" not supplied on the command-line.\n"
+"\n"
+"APOP:\n"
+" Same as POP, but using Authenticated POP:\n"
+"    apop username:password at server\n"
+"\n"
+"IMAP:\n"
+" Connect to an IMAP server. This supports the same notation as that of\n"
+" POP mail.\n"
+"    imap username:password at server\n"
+" It also allows you to specify a specific mailbox other than INBOX using\n"
+" this format:\n"
+"    imap username:password at server mailbox\n"
+"\n"
+"IMAPS:\n"
+" Connect to an IMAP server over ssl.\n"
+" This supports the same notation as IMAP.\n"
+"    imaps username:password at server [mailbox]\n"
+"\n"
+msgstr ""
+"用法:%(program)s [-v] [-c] [[-C class] -S field=value]* <instance home> [method]\n"
+"\n"
+"选项:\n"
+" -v: 打印版本并且退出\n"
+" -c: 用来创建条目的缺省类型(其它是tracker的MAIL_DEFAULT_CLASS)\n"
+" -C / -S: 看下面\n"
+"\n"
+"Roundup 邮件网关会以四种方式被调用:\n"
+" . 实例起始目录作为唯一参数,\n"
+" . 实例起始目录和邮件脱机(spool)文件,\n"
+" . 实例起始目录和 POP/APOP 服务器帐号,或者\n"
+" . 实例起始目录和 IMAP/IMAPS 服务器帐号。\n"
+"\n"
+"也支持使用可选的 -C 或 -S 参数,它们允许你为roundup-mailgw所创建的类\n"
+"设置域。如果没有指定,则缺省的类是 msg,但是其它的类:issue, file, user\n"
+"也可以使用。-S 或 --set 选项使用 property=value[;property=value] 表示法,\n"
+"它们可以被 Roundup 命令的命令行或可以指定一封邮件信息标题行的命令所接受。\n"
+"\n"
+"它可以让你给每封邮件设置信息的类型。\n"
+"\n"
+"PIPE:\n"
+" 在第一种方式下,邮件网关从标准输入读取单条信息,并将信息提交给 roundup.mailgw\n"
+" 模块。\n"
+"\n"
+"UNIX mailbox:\n"
+" 在第二种方式下,网关从邮件脱机文件中读取所有的信息,并按顺序提交给\n"
+" roundup.mailgw 模块。一旦所有信息被成功处理,文件被清空。这个文件被\n"
+" 指定为:\n"
+"   mailbox /path/to/mailbox\n"
+"\n"
+"POP:\n"
+" 在第三种方式下,网关从指定的 POP 服务器读出所有信息,并按顺序提交到\n"
+" roundup.mailgw 模块。服务器被指定为:\n"
+"    pop username:password at server\n"
+" 用户名和口令可以被省略:\n"
+"    pop username at server\n"
+"    pop server\n"
+" 都是有效的。如果没有提供用户名或口令都将在命令行被提示。\n"
+"\n"
+"APOP:\n"
+" 同 POP,但使用认证的 POP:\n"
+"    apop username:password at server\n"
+"\n"
+"IMAP:\n"
+" 联接到 IMAP 服务器。它支持同 POP 邮件相同的写法。\n"
+"    imap username:password at server\n"
+" 除了 INBOX 外还允许你指定一个特别的邮箱,\n"
+" 使用这种格式:    imap username:password at server mailbox\n"
+"\n"
+"IMAPS:\n"
+" 通过ssl联接到 IMAP 服务器。\n"
+" 它支持同 IMAP 一样的写法。\n"
+"    imaps username:password at server [mailbox]\n"
+"\n"
+
+#: ../roundup/scripts/roundup_mailgw.py:147
+msgid "Error: not enough source specification information"
+msgstr "错误:没有足够的源协议信息"
+
+#: ../roundup/scripts/roundup_mailgw.py:157
+msgid "Error: pop specification not valid"
+msgstr "错误:pop协议无效"
+
+#: ../roundup/scripts/roundup_mailgw.py:164
+msgid "Error: apop specification not valid"
+msgstr "错误:apop协议无效"
+
+#: ../roundup/scripts/roundup_mailgw.py:178
+msgid "Error: The source must be either \"mailbox\", \"pop\", \"apop\", \"imap\" or \"imaps\""
+msgstr "错误:源必须是 \"mailbox\", \"pop\", \"apop\", \"imap\" 或者 \"imaps\" 之一"
+
+#: ../roundup/scripts/roundup_server.py:106
+msgid ""
+"<html><head><title>Roundup trackers index</title></head>\n"
+"<body><h1>Roundup trackers index</h1><ol>\n"
+msgstr ""
+"<html><head><title>Roundup tracker 索引</title></head>\n"
+"<body><h1>Roundup tracker 索引</h1><ol>\n"
+
+#: ../roundup/scripts/roundup_server.py:217
+#, python-format
+msgid "Error: %s: %s"
+msgstr "错误:%s: %s"
+
+#: ../roundup/scripts/roundup_server.py:325
+msgid ""
+" -c <Command>  Windows Service options.\n"
+"               If you want to run the server as a Windows Service, you\n"
+"               must configure the rest of the options by changing the\n"
+"               constants of this program.  You will at least configure\n"
+"               one tracker in the TRACKER_HOMES variable.  This option\n"
+"               is mutually exclusive from the rest.  Typing\n"
+"               \"roundup-server -c help\" shows Windows Services\n"
+"               specifics."
+msgstr ""
+" -c <Command>  Windows 服务选项。\n"
+"               如果你想把server作为一个Windows服务来运行,你必须通过修改\n"
+"               这个程序的常量来配置此选项的其它内容。你至少需要在 TRACKER_HOMES\n"
+"               变量上配置一个tracker。这个选项与其经选项是互斥的。打入\n"
+"               \"roundup-server -c help\" 来了解Windows服务的规范。"
+
+#: ../roundup/scripts/roundup_server.py:334
+msgid ""
+" -u <UID>      runs the Roundup web server as this UID\n"
+" -g <GID>      runs the Roundup web server as this GID\n"
+" -d <PIDfile>  run the server in the background and write the server's PID\n"
+"               to the file indicated by PIDfile. The -l option *must* be\n"
+"               specified if -d is used."
+msgstr ""
+" -u <UID>      以这个 UID 来运行 Roundup web 服务器\n"
+" -g <GID>      以这个 GID 来运行 Roundup web 服务器\n"
+" -d <PIDfile>  在后台运行服务器,并且将服务器的 PID 写入指定的 PIDFile 中去。\n"
+"               如果使用了 -d 选项,则 -l 选项 *必须* 要指定。"
+
+#: ../roundup/scripts/roundup_server.py:342
+#, python-format
+msgid ""
+"%(message)sUsage: roundup-server [options] [name=tracker home]*\n"
+"\n"
+"Options:\n"
+" -v            prints the Roundup version number and exits\n"
+" -C <fname>    use configuration file\n"
+" -n <name>     sets the host name of the Roundup web server instance\n"
+" -p <port>     sets the port to listen on (default: %(port)s)\n"
+" -l <fname>    log to the file indicated by fname instead of stderr/stdout\n"
+" -N            log client machine names instead of IP addresses (much slower)\n"
+"%(os_part)s\n"
+"\n"
+"Examples:\n"
+" roundup-server -C /opt/roundup/etc/roundup-server.ini\n"
+"\n"
+" roundup-server support=/var/spool/roundup-trackers/support\n"
+"\n"
+" roundup-server -d /var/run/roundup.pid -l /var/log/roundup.log \\\n"
+"     support=/var/spool/roundup-trackers/support\n"
+"\n"
+"Configuration file format:\n"
+"   See the \"admin_guide\" in the Roundup \"doc\" directory.\n"
+"\n"
+"How to use \"name=tracker home\":\n"
+"   These arguments set the tracker home(s) to use. The name is how the\n"
+"   tracker is identified in the URL (it's the first part of the URL path).\n"
+"   The tracker home is the directory that was identified when you did\n"
+"   \"roundup-admin init\". You may specify any number of these name=home\n"
+"   pairs on the command-line. Make sure the name part doesn't include\n"
+"   any url-unsafe characters like spaces, as these confuse IE.\n"
+msgstr ""
+"%(message)s用法:roundup-server [options] [name=tracker home]*\n"
+"\n"
+"选项:\n"
+" -v            打印 Roundup 的版本号并且退出\n"
+" -C <fname>    使用配置文件\n"
+" -n <name>     设置 Roundup web 服务器实例的主机名\n"
+" -p <port>     设置监听端口(缺省:%(port)s)\n"
+" -l <fname>    将日志输出到由 fname 指定的文件中去,而不是 标准错误/标准输出\n"
+" -N            将客户端机器的名字而不是IP地址记录到日志中去(可能会慢点)\n"
+"%(os_part)s\n"
+"\n"
+"举例:\n"
+" roundup-server -C /opt/roundup/etc/roundup-server.ini\n"
+"\n"
+" roundup-server support=/var/spool/roundup-trackers/support\n"
+"\n"
+" roundup-server -d /var/run/roundup.pid -l /var/log/roundup.log \\\n"
+"     support=/var/spool/roundup-trackers/support\n"
+"\n"
+"配置文件格式:\n"
+"   查阅在Roundup \"doc\" 目录下的 \"admin_guide\" 。\n"
+"\n"
+"如何使用 \"name=tracker home\":\n"
+"   这些参数用来设置要使用的tracker的起始目录。name 会在URL中用来\n"
+"   定位tracker(它是 URL 路径的第一部分)。tracker home 是在你执行\n"
+"   \"roundup-admin init\" 时所指定的目录。你可以在命令行上指定任\n"
+"   意数量的 name=home 对。要确保 name 部分不能包括任何非url安全的\n"
+"   字符,象空格,因为它们会把IE搞乱。\n"
+
+#: ../roundup/scripts/roundup_server.py:418
+msgid "WARNING: ignoring \"-g\" argument, not root"
+msgstr "警告:忽略 \"-g\" 参数,不是 root"
+
+#: ../roundup/scripts/roundup_server.py:424
+msgid "Can't change groups - no grp module"
+msgstr "不能修改组 - 无 grp 模块"
+
+#: ../roundup/scripts/roundup_server.py:433
+#, python-format
+msgid "Group %(group)s doesn't exist"
+msgstr "组 %(group)s 不存在"
+
+#: ../roundup/scripts/roundup_server.py:444
+msgid "Can't run as root!"
+msgstr "不能以 root 运行!"
+
+#: ../roundup/scripts/roundup_server.py:447
+msgid "WARNING: ignoring \"-u\" argument, not root"
+msgstr "警告:忽略 \"-u\" 参数,不是 root"
+
+#: ../roundup/scripts/roundup_server.py:452
+msgid "Can't change users - no pwd module"
+msgstr "不能修改用户 - 无 pwd 模块"
+
+#: ../roundup/scripts/roundup_server.py:461
+#, python-format
+msgid "User %(user)s doesn't exist"
+msgstr "用户 %(user)s 不存在"
+
+#: ../roundup/scripts/roundup_server.py:575
+msgid "Instances must be name=home"
+msgstr "实例必须是 实例名=实例路径"
+
+#: ../roundup/scripts/roundup_server.py:589
+#, python-format
+msgid "Configuration saved to %s"
+msgstr "配置保存到 %s"
+
+#: ../roundup/scripts/roundup_server.py:606
+#, python-format
+msgid "Unable to bind to port %s, port already in use."
+msgstr "无法绑定到端口 %s, 端口已经被占用。"
+
+#: ../roundup/scripts/roundup_server.py:625
+msgid "Sorry, you can't run the server as a daemon on this Operating System"
+msgstr "抱歉,在这个操作系统上不能以守护进程的方式来运行服务"
+
+#: ../roundup/scripts/roundup_server.py:639
+#, python-format
+msgid "Roundup server started on %(HOST)s:%(PORT)s"
+msgstr "Roundup server 启动于 %(HOST)s:%(PORT)s"
+
+#: ../templates/classic/html/_generic.collision.html:4
+#: ../templates/minimal/html/_generic.collision.html:4
+msgid "${class} Edit Collision - ${tracker}"
+msgstr "${class} 编辑冲突 - ${tracker}"
+
+#: ../templates/classic/html/_generic.collision.html:7
+#: ../templates/minimal/html/_generic.collision.html:7
+msgid "${class} Edit Collision"
+msgstr "${class} 编辑冲突"
+
+#: ../templates/classic/html/_generic.collision.html:14
+#: ../templates/minimal/html/_generic.collision.html:14
+msgid ""
+"\n"
+"  There has been a collision. Another user updated this node\n"
+"  while you were editing. Please <a href='${context}'>reload</a>\n"
+"  the node and review your edits.\n"
+msgstr ""
+"\n"
+"  存在冲突。另一个用户在你编辑时更新了此条记录。\n"
+"  请 <a href='${context}'>重新载入</a> 记录查看你的编辑。\n"
+
+#: ../templates/classic/html/_generic.help.html:9
+#: ../templates/minimal/html/_generic.help.html:9
+msgid "${property} help - ${tracker}"
+msgstr "${property} 帮助 - ${tracker}"
+
+#: ../templates/classic/html/_generic.help.html:30
+#: ../templates/minimal/html/_generic.help.html:30
+msgid " Cancel "
+msgstr "取消"
+
+#: ../templates/classic/html/_generic.help.html:33
+#: ../templates/minimal/html/_generic.help.html:33
+msgid " Apply "
+msgstr "应用"
+
+#: ../templates/classic/html/_generic.help.html:40
+#: ../templates/classic/html/issue.index.html:67
+#: ../templates/minimal/html/_generic.help.html:40
+msgid "&lt;&lt; previous"
+msgstr "&lt;&lt; 向上"
+
+#: ../templates/classic/html/_generic.help.html:50
+#: ../templates/classic/html/issue.index.html:75
+#: ../templates/minimal/html/_generic.help.html:50
+msgid "${start}..${end} out of ${total}"
+msgstr "${start}..${end} 全部 ${total}"
+
+#: ../templates/classic/html/_generic.help.html:54
+#: ../templates/classic/html/issue.index.html:78
+#: ../templates/minimal/html/_generic.help.html:54
+msgid "next &gt;&gt;"
+msgstr "向下 &gt;&gt;"
+
+#: ../templates/classic/html/_generic.index.html:6
+#: ../templates/classic/html/_generic.item.html:4
+#: ../templates/minimal/html/_generic.index.html:6
+#: ../templates/minimal/html/_generic.item.html:4
+msgid "${class} editing - ${tracker}"
+msgstr "${class} 编辑 - ${tracker}"
+
+#: ../templates/classic/html/_generic.index.html:9
+#: ../templates/classic/html/_generic.item.html:7
+#: ../templates/minimal/html/_generic.index.html:9
+#: ../templates/minimal/html/_generic.item.html:7
+msgid "${class} editing"
+msgstr "${class} 编辑"
+
+#: ../templates/classic/html/_generic.index.html:14
+#: ../templates/classic/html/_generic.item.html:12
+#: ../templates/classic/html/file.item.html:9
+#: ../templates/classic/html/issue.index.html:10
+#: ../templates/classic/html/user.index.html:9
+#: ../templates/minimal/html/_generic.index.html:14
+#: ../templates/minimal/html/_generic.item.html:12
+#: ../templates/minimal/html/user.index.html:9
+#: ../templates/minimal/html/user.item.html:18
+#: ../templates/minimal/html/user.register.html:14
+msgid "You are not allowed to view this page."
+msgstr "你不允许查看此页"
+
+#: ../templates/classic/html/_generic.index.html:22
+#: ../templates/minimal/html/_generic.index.html:22
+msgid "<p class=\"form-help\"> You may edit the contents of the ${classname} class using this form. Commas, newlines and double quotes (\") must be handled delicately. You may include commas and newlines by enclosing the values in double-quotes (\"). Double quotes themselves must be quoted by doubling (\"\"). </p> <p class=\"form-help\"> Multilink properties have their multiple values colon (\":\") separated (... ,\"one:two:three\", ...) </p> <p class=\"form-help\"> Remove entries by deleting their line. Add new entries by appending them to the table - put an X in the id column. </p>"
+msgstr "<p class=\"form-help\"> 你可以使用这个表格来编辑 ${classname} 类别。 逗号,换行和双引号(\")必须被小心处理。你可以在双引号(\")中包含逗号和换行。双引号本身必须被两个(\"\")所包括。</p> <p class=\"form-help\"> Multilink 属性有多个值,这些值用冒号(\":\")分隔(...,\"一:二:三\",...) </p> <p class=\"form-help\"> 通过删除它们所在的行来删除项。追加一条新记录到表中 - 在 id 列置上一个 X 。</p>"
+
+#: ../templates/classic/html/_generic.index.html:44
+#: ../templates/minimal/html/_generic.index.html:44
+msgid "Edit Items"
+msgstr "编辑项目"
+
+#: ../templates/classic/html/file.index.html:4
+msgid "List of files - ${tracker}"
+msgstr "文件列表 - ${tracker}"
+
+#: ../templates/classic/html/file.index.html:5
+msgid "List of files"
+msgstr "文件列表"
+
+#: ../templates/classic/html/file.index.html:10
+msgid "Download"
+msgstr "下载"
+
+#: ../templates/classic/html/file.index.html:11
+#: ../templates/classic/html/file.item.html:23
+#: ../templates/classic/html/file.item.html:51
+msgid "Content Type"
+msgstr "内容类型"
+
+#: ../templates/classic/html/file.index.html:12
+msgid "Uploaded By"
+msgstr "上传由"
+
+#: ../templates/classic/html/file.index.html:13
+#: ../templates/classic/html/msg.item.html:38
+msgid "Date"
+msgstr "日期"
+
+#: ../templates/classic/html/file.item.html:2
+msgid "File display - ${tracker}"
+msgstr "文件显示 - ${tracker}"
+
+#: ../templates/classic/html/file.item.html:4
+msgid "File display"
+msgstr "文件显示"
+
+#: ../templates/classic/html/file.item.html:19
+#: ../templates/classic/html/file.item.html:47
+#: ../templates/classic/html/user.item.html:34
+#: ../templates/classic/html/user.register.html:17
+msgid "Name"
+msgstr "姓名"
+
+#: ../templates/classic/html/file.item.html:41
+msgid "download"
+msgstr "下载"
+
+#: ../templates/classic/html/home.classlist.html:2
+#: ../templates/minimal/html/home.classlist.html:2
+msgid "List of classes - ${tracker}"
+msgstr "类别列表 - ${tracker}"
+
+#: ../templates/classic/html/home.classlist.html:4
+#: ../templates/minimal/html/home.classlist.html:4
+msgid "List of classes"
+msgstr "类别列表"
+
+#: ../templates/classic/html/issue.index.html:4
+msgid "List of issues - ${tracker}"
+msgstr "问题列表 - ${tracker}"
+
+#: ../templates/classic/html/issue.index.html:6
+msgid "List of issues"
+msgstr "问题列表"
+
+#: ../templates/classic/html/issue.index.html:17
+#: ../templates/classic/html/issue.item.html:38
+msgid "Priority"
+msgstr "优先级"
+
+#: ../templates/classic/html/issue.index.html:18
+msgid "ID"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:19
+msgid "Creation"
+msgstr "创建时间"
+
+#: ../templates/classic/html/issue.index.html:20
+msgid "Activity"
+msgstr "活跃度"
+
+#: ../templates/classic/html/issue.index.html:21
+msgid "Actor"
+msgstr "执行者"
+
+#: ../templates/classic/html/issue.index.html:22
+msgid "Topic"
+msgstr "主题"
+
+#: ../templates/classic/html/issue.index.html:23
+#: ../templates/classic/html/issue.item.html:33
+msgid "Title"
+msgstr "标题"
+
+#: ../templates/classic/html/issue.index.html:24
+#: ../templates/classic/html/issue.item.html:40
+msgid "Status"
+msgstr "状态"
+
+#: ../templates/classic/html/issue.index.html:25
+msgid "Creator"
+msgstr "创建人"
+
+#: ../templates/classic/html/issue.index.html:26
+msgid "Assigned&nbsp;To"
+msgstr "分配给"
+
+#: ../templates/classic/html/issue.index.html:90
+msgid "Download as CSV"
+msgstr "以CSV格式下载"
+
+#: ../templates/classic/html/issue.index.html:98
+msgid "Sort on:"
+msgstr "排序按:"
+
+#: ../templates/classic/html/issue.index.html:101
+#: ../templates/classic/html/issue.index.html:118
+msgid "- nothing -"
+msgstr "- æ—  -"
+
+#: ../templates/classic/html/issue.index.html:109
+#: ../templates/classic/html/issue.index.html:126
+msgid "Descending:"
+msgstr "降序:"
+
+#: ../templates/classic/html/issue.index.html:115
+msgid "Group on:"
+msgstr "分组:"
+
+#: ../templates/classic/html/issue.index.html:132
+msgid "Redisplay"
+msgstr "刷新"
+
+#: ../templates/classic/html/issue.item.html:7
+msgid "Issue ${id}: ${title} - ${tracker}"
+msgstr "问题 ${id}: ${title} - ${tracker}"
+
+#: ../templates/classic/html/issue.item.html:10
+msgid "New Issue - ${tracker}"
+msgstr "新问题 - ${tracker}"
+
+#: ../templates/classic/html/issue.item.html:14
+msgid "New Issue"
+msgstr "新问题"
+
+#: ../templates/classic/html/issue.item.html:16
+msgid "New Issue Editing"
+msgstr "新问题编辑"
+
+#: ../templates/classic/html/issue.item.html:19
+msgid "Issue${id}"
+msgstr "问题 [${id}]"
+
+#: ../templates/classic/html/issue.item.html:22
+msgid "Issue${id} Editing"
+msgstr "问题 [${id}] 编辑"
+
+#: ../templates/classic/html/issue.item.html:45
+msgid "Superseder"
+msgstr "推迟"
+
+#: ../templates/classic/html/issue.item.html:50
+msgid "View: ${link}"
+msgstr "查看:${link}"
+
+#: ../templates/classic/html/issue.item.html:54
+msgid "Nosy List"
+msgstr "杂事列表"
+
+#: ../templates/classic/html/issue.item.html:63
+msgid "Assigned To"
+msgstr "分配给"
+
+#: ../templates/classic/html/issue.item.html:65
+msgid "Topics"
+msgstr "主题"
+
+#: ../templates/classic/html/issue.item.html:73
+msgid "Change Note"
+msgstr "修改记录"
+
+#: ../templates/classic/html/issue.item.html:81
+msgid "File"
+msgstr "文件"
+
+#: ../templates/classic/html/issue.item.html:100
+msgid "<table class=\"form\"> <tr> <td>Note:&nbsp;</td> <th class=\"required\">highlighted</th> <td>&nbsp;fields are required.</td> </tr> </table>"
+msgstr "<table class=\"form\"> <tr> <td>注意:&nbsp;</td> <th class=\"required\">高亮</th> <td>&nbsp;字段是必须的。</td> </tr> </table>"
+
+#: ../templates/classic/html/issue.item.html:114
+msgid "Created on <b>${creation}</b> by <b>${creator}</b>, last changed <b>${activity}</b> by <b>${actor}</b>."
+msgstr "在 <b>${creation}</b> 由 <b>${creator}</b> 创建,最后由 <b>${actor}</b> 修改为 <b>${activity}</b>。"
+
+#: ../templates/classic/html/issue.item.html:118
+#: ../templates/classic/html/msg.item.html:51
+msgid "Files"
+msgstr "文件"
+
+#: ../templates/classic/html/issue.item.html:120
+#: ../templates/classic/html/msg.item.html:53
+msgid "File name"
+msgstr "文件名"
+
+#: ../templates/classic/html/issue.item.html:121
+#: ../templates/classic/html/msg.item.html:54
+msgid "Uploaded"
+msgstr "已上传"
+
+#: ../templates/classic/html/issue.item.html:122
+msgid "Type"
+msgstr "类型"
+
+#: ../templates/classic/html/issue.item.html:123
+#: ../templates/classic/html/query.edit.html:30
+msgid "Edit"
+msgstr "编辑"
+
+#: ../templates/classic/html/issue.item.html:124
+msgid "Remove"
+msgstr "删除"
+
+#: ../templates/classic/html/issue.item.html:144
+#: ../templates/classic/html/issue.item.html:165
+#: ../templates/classic/html/query.edit.html:50
+msgid "remove"
+msgstr "删除"
+
+#: ../templates/classic/html/issue.item.html:151
+#: ../templates/classic/html/msg.index.html:9
+msgid "Messages"
+msgstr "信息"
+
+#: ../templates/classic/html/issue.item.html:155
+msgid "msg${id} (view)"
+msgstr "msg${id} (查看)"
+
+#: ../templates/classic/html/issue.item.html:156
+msgid "Author: ${author}"
+msgstr "作者:${author}"
+
+#: ../templates/classic/html/issue.item.html:158
+msgid "Date: ${date}"
+msgstr "日期:${date}"
+
+#: ../templates/classic/html/issue.search.html:2
+msgid "Issue searching - ${tracker}"
+msgstr "问题搜索 - ${tracker}"
+
+#: ../templates/classic/html/issue.search.html:4
+msgid "Issue searching"
+msgstr "问题搜索"
+
+#: ../templates/classic/html/issue.search.html:25
+msgid "Filter on"
+msgstr "过滤按"
+
+#: ../templates/classic/html/issue.search.html:26
+msgid "Display"
+msgstr "显示"
+
+#: ../templates/classic/html/issue.search.html:27
+msgid "Sort on"
+msgstr "排序按 "
+
+#: ../templates/classic/html/issue.search.html:28
+msgid "Group on"
+msgstr "分组按"
+
+#: ../templates/classic/html/issue.search.html:32
+msgid "All text*:"
+msgstr "所有文本*"
+
+#: ../templates/classic/html/issue.search.html:40
+msgid "Title:"
+msgstr "标题:"
+
+#: ../templates/classic/html/issue.search.html:50
+msgid "Topic:"
+msgstr "主题:"
+
+#: ../templates/classic/html/issue.search.html:58
+msgid "ID:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:66
+msgid "Creation Date:"
+msgstr "创建时间:"
+
+#: ../templates/classic/html/issue.search.html:77
+msgid "Creator:"
+msgstr "创建人:"
+
+#: ../templates/classic/html/issue.search.html:79
+msgid "created by me"
+msgstr "由我创建"
+
+#: ../templates/classic/html/issue.search.html:88
+msgid "Activity:"
+msgstr "活跃度:"
+
+#: ../templates/classic/html/issue.search.html:99
+msgid "Actor:"
+msgstr "执行人:"
+
+#: ../templates/classic/html/issue.search.html:101
+msgid "done by me"
+msgstr "由我完成"
+
+#: ../templates/classic/html/issue.search.html:112
+msgid "Priority:"
+msgstr "优先级:"
+
+#: ../templates/classic/html/issue.search.html:114
+#: ../templates/classic/html/issue.search.html:130
+msgid "not selected"
+msgstr "未选择"
+
+#: ../templates/classic/html/issue.search.html:125
+msgid "Status:"
+msgstr "状态:"
+
+#: ../templates/classic/html/issue.search.html:128
+msgid "not resolved"
+msgstr "未解决"
+
+#: ../templates/classic/html/issue.search.html:143
+msgid "Assigned to:"
+msgstr "分配给:"
+
+#: ../templates/classic/html/issue.search.html:146
+msgid "assigned to me"
+msgstr "分配给我"
+
+#: ../templates/classic/html/issue.search.html:148
+msgid "unassigned"
+msgstr "未分配"
+
+#: ../templates/classic/html/issue.search.html:158
+msgid "Pagesize:"
+msgstr "页大小:"
+
+#: ../templates/classic/html/issue.search.html:164
+msgid "Start With:"
+msgstr "开始在:"
+
+#: ../templates/classic/html/issue.search.html:170
+msgid "Sort Descending:"
+msgstr "降序排列:"
+
+#: ../templates/classic/html/issue.search.html:177
+msgid "Group Descending:"
+msgstr "降序分组:"
+
+#: ../templates/classic/html/issue.search.html:184
+msgid "Query name**:"
+msgstr "查询 名字**"
+
+#: ../templates/classic/html/issue.search.html:194
+#: ../templates/classic/html/page.html:47
+msgid "Search"
+msgstr "搜索"
+
+#: ../templates/classic/html/issue.search.html:198
+msgid "*: The \"all text\" field will look in message bodies and issue titles<br> **: If you supply a name, the query will be saved off and available as a link in the sidebar"
+msgstr "*: 在信息体和问题标题上的 \"所有的文本\" 字段都将被查找<br> **: 如果你提供了一个名字,这个查询将被保存并且作为一个链接出现在侧边栏上"
+
+#: ../templates/classic/html/keyword.item.html:3
+msgid "Keyword editing - ${tracker}"
+msgstr "关键字编辑 - ${tracker}"
+
+#: ../templates/classic/html/keyword.item.html:5
+msgid "Keyword editing"
+msgstr "关键字编辑"
+
+#: ../templates/classic/html/keyword.item.html:11
+msgid "Existing Keywords"
+msgstr "存在的关键字"
+
+#: ../templates/classic/html/keyword.item.html:20
+msgid "To edit an existing keyword (for spelling or typing errors), click on its entry above."
+msgstr "为编辑一个存在的关键字(由于拼写或打字错误),在上面的项目上点击。"
+
+#: ../templates/classic/html/keyword.item.html:27
+msgid "To create a new keyword, enter it below and click \"Submit New Entry\"."
+msgstr "想要创建新的关键字,请点击下面的 \"提交新的项\"。"
+
+#: ../templates/classic/html/keyword.item.html:37
+msgid "Keyword"
+msgstr "关键字"
+
+#: ../templates/classic/html/msg.index.html:3
+msgid "List of messages - ${tracker}"
+msgstr "信息列表 - ${tracker}"
+
+#: ../templates/classic/html/msg.index.html:5
+msgid "Message listing"
+msgstr "信息列表"
+
+#: ../templates/classic/html/msg.item.html:6
+msgid "Message ${id} - ${tracker}"
+msgstr "信息 [${id}] - ${tracker}"
+
+#: ../templates/classic/html/msg.item.html:9
+msgid "New Message - ${tracker}"
+msgstr "新信息 - ${tracker}"
+
+#: ../templates/classic/html/msg.item.html:13
+msgid "New Message"
+msgstr "新信息"
+
+#: ../templates/classic/html/msg.item.html:15
+msgid "New Message Editing"
+msgstr "新信息编辑"
+
+#: ../templates/classic/html/msg.item.html:18
+msgid "Message${id}"
+msgstr "信息 [${id}]"
+
+#: ../templates/classic/html/msg.item.html:21
+msgid "Message${id} Editing"
+msgstr "信息 [${id}] 编辑"
+
+#: ../templates/classic/html/msg.item.html:28
+msgid "Author"
+msgstr "作者"
+
+#: ../templates/classic/html/msg.item.html:33
+msgid "Recipients"
+msgstr "收信人"
+
+#: ../templates/classic/html/msg.item.html:44
+msgid "Content"
+msgstr "内容"
+
+#: ../templates/classic/html/page.html:28
+msgid "<b>Your Queries</b> (<a href=\"query?@template=edit\">edit</a>)"
+msgstr "<b>我的查询</b> (<a href=\"query?@template=edit\">编辑</a>)"
+
+#: ../templates/classic/html/page.html:39
+msgid "Issues"
+msgstr "问题"
+
+#: ../templates/classic/html/page.html:41
+#: ../templates/classic/html/page.html:60
+msgid "Create New"
+msgstr "新建"
+
+#: ../templates/classic/html/page.html:43
+msgid "Show Unassigned"
+msgstr "显示未分配"
+
+#: ../templates/classic/html/page.html:45
+msgid "Show All"
+msgstr "显示所有"
+
+#: ../templates/classic/html/page.html:48
+msgid "Show issue:"
+msgstr "显示问题:"
+
+#: ../templates/classic/html/page.html:58
+msgid "Keywords"
+msgstr "关键字"
+
+#: ../templates/classic/html/page.html:64
+msgid "Edit Existing"
+msgstr "编辑已经存在的"
+
+#: ../templates/classic/html/page.html:70
+#: ../templates/minimal/html/page.html:48
+msgid "Administration"
+msgstr "管理"
+
+#: ../templates/classic/html/page.html:72
+#: ../templates/minimal/html/page.html:49
+msgid "Class List"
+msgstr "类列表"
+
+#: ../templates/classic/html/page.html:76
+#: ../templates/minimal/html/page.html:51
+msgid "User List"
+msgstr "用户列表"
+
+#: ../templates/classic/html/page.html:78
+#: ../templates/minimal/html/page.html:54
+msgid "Add User"
+msgstr "增加用户"
+
+#: ../templates/classic/html/page.html:85
+#: ../templates/classic/html/page.html:89
+#: ../templates/minimal/html/page.html:30
+msgid "Login"
+msgstr "登录"
+
+#: ../templates/classic/html/page.html:91
+#: ../templates/classic/html/user.register.html:63
+#: ../templates/minimal/html/page.html:33
+#: ../templates/minimal/html/user.register.html:58
+msgid "Register"
+msgstr "注册"
+
+#: ../templates/classic/html/page.html:94
+msgid "Lost&nbsp;your&nbsp;login?"
+msgstr "忘记你的登入口令了?"
+
+#: ../templates/classic/html/page.html:99
+msgid "Hello, ${user}"
+msgstr "你好,${user}"
+
+#: ../templates/classic/html/page.html:101
+msgid "Your Issues"
+msgstr "我的问题"
+
+#: ../templates/classic/html/page.html:102
+#: ../templates/minimal/html/page.html:40
+msgid "Your Details"
+msgstr "我的信息"
+
+#: ../templates/classic/html/page.html:104
+#: ../templates/minimal/html/page.html:42
+msgid "Logout"
+msgstr "注销"
+
+#: ../templates/classic/html/page.html:108
+msgid "Help"
+msgstr "帮助"
+
+#: ../templates/classic/html/page.html:109
+msgid "Roundup docs"
+msgstr "Roundup文档"
+
+#: ../templates/classic/html/page.html:160
+msgid "don't care"
+msgstr "不用关心"
+
+#: ../templates/classic/html/page.html:162
+msgid "------------"
+msgstr ""
+
+#: ../templates/classic/html/page.html:187
+msgid "no value"
+msgstr "无值"
+
+#: ../templates/classic/html/query.edit.html:4
+msgid "\"Your Queries\" Editing - ${tracker}"
+msgstr "\"我的查询\" 修改 - ${tracker}"
+
+#: ../templates/classic/html/query.edit.html:6
+msgid "\"Your Queries\" Editing"
+msgstr "\"我的查询\"修改"
+
+#: ../templates/classic/html/query.edit.html:11
+msgid "You are not allowed to edit queries."
+msgstr "不允许编辑查询"
+
+#: ../templates/classic/html/query.edit.html:28
+msgid "Query"
+msgstr "查询"
+
+#: ../templates/classic/html/query.edit.html:29
+msgid "Include in \"Your Queries\""
+msgstr "包括在\"我的查询\"中"
+
+#: ../templates/classic/html/query.edit.html:31
+msgid "Private to you?"
+msgstr "是私人信息吗?"
+
+#: ../templates/classic/html/query.edit.html:44
+msgid "leave out"
+msgstr "省略"
+
+#: ../templates/classic/html/query.edit.html:45
+msgid "include"
+msgstr "包含"
+
+#: ../templates/classic/html/query.edit.html:49
+msgid "leave in"
+msgstr "留下"
+
+#: ../templates/classic/html/query.edit.html:54
+msgid "[query is retired]"
+msgstr "[查询过期了]"
+
+#: ../templates/classic/html/query.edit.html:67
+msgid "edit"
+msgstr "编辑"
+
+#: ../templates/classic/html/query.edit.html:71
+msgid "yes"
+msgstr "是"
+
+#: ../templates/classic/html/query.edit.html:73
+msgid "no"
+msgstr "否"
+
+#: ../templates/classic/html/query.edit.html:79
+msgid "Delete"
+msgstr "删除"
+
+#: ../templates/classic/html/query.edit.html:90
+msgid "[not yours to edit]"
+msgstr "[不由你修改]"
+
+#: ../templates/classic/html/query.edit.html:96
+msgid "Save Selection"
+msgstr "保存选择"
+
+#: ../templates/classic/html/user.forgotten.html:3
+msgid "Password reset request - ${tracker}"
+msgstr "口令重设请求 - ${tracker}"
+
+#: ../templates/classic/html/user.forgotten.html:5
+msgid "Password reset request"
+msgstr "口令重设请求"
+
+#: ../templates/classic/html/user.forgotten.html:9
+msgid "You have two options if you have forgotten your password. If you know the email address you registered with, enter it below."
+msgstr "如果你忘了口令将有两种选择。如果你知道注册时的邮件地址,在下面输入它。"
+
+#: ../templates/classic/html/user.forgotten.html:16
+msgid "Email Address:"
+msgstr "邮件地址:"
+
+#: ../templates/classic/html/user.forgotten.html:24
+#: ../templates/classic/html/user.forgotten.html:34
+msgid "Request password reset"
+msgstr "请求口令重设"
+
+#: ../templates/classic/html/user.forgotten.html:30
+msgid "Or, if you know your username, then enter it below."
+msgstr "或者,如果你知道你的用户名,则在下面输入它。"
+
+#: ../templates/classic/html/user.forgotten.html:33
+msgid "Username:"
+msgstr "用户名:"
+
+#: ../templates/classic/html/user.forgotten.html:39
+msgid "A confirmation email will be sent to you - please follow the instructions within it to complete the reset process."
+msgstr "将发给你一封确认信 - 请按照其中的指令来完成重置处理。"
+
+#: ../templates/classic/html/user.index.html:3
+#: ../templates/minimal/html/user.index.html:3
+msgid "User listing - ${tracker}"
+msgstr "用户列表 - ${tracker}"
+
+#: ../templates/classic/html/user.index.html:5
+#: ../templates/minimal/html/user.index.html:5
+msgid "User listing"
+msgstr "用户列表"
+
+#: ../templates/classic/html/user.index.html:14
+#: ../templates/minimal/html/user.index.html:14
+msgid "Username"
+msgstr "用户名"
+
+#: ../templates/classic/html/user.index.html:15
+msgid "Real name"
+msgstr "真实姓名"
+
+#: ../templates/classic/html/user.index.html:16
+#: ../templates/classic/html/user.item.html:65
+#: ../templates/classic/html/user.register.html:45
+msgid "Organisation"
+msgstr "组织"
+
+#: ../templates/classic/html/user.index.html:17
+#: ../templates/minimal/html/user.index.html:15
+msgid "Email address"
+msgstr "邮件地址"
+
+#: ../templates/classic/html/user.index.html:18
+msgid "Phone number"
+msgstr "电话号码"
+
+#: ../templates/classic/html/user.index.html:19
+msgid "Retire"
+msgstr "收回"
+
+#: ../templates/classic/html/user.index.html:32
+msgid "retire"
+msgstr "收回"
+
+#: ../templates/classic/html/user.item.html:7
+msgid "User ${id}: ${title} - ${tracker}"
+msgstr "用户 [${id}]: ${title} - ${tracker}"
+
+#: ../templates/classic/html/user.item.html:10
+msgid "New User - ${tracker}"
+msgstr "新用户 - ${tracker}"
+
+#: ../templates/classic/html/user.item.html:14
+#: ../templates/minimal/html/user.item.html:6
+msgid "New User"
+msgstr "新用户"
+
+#: ../templates/classic/html/user.item.html:16
+#: ../templates/minimal/html/user.item.html:8
+msgid "New User Editing"
+msgstr "新用户编辑"
+
+#: ../templates/classic/html/user.item.html:19
+#: ../templates/minimal/html/user.item.html:11
+msgid "User${id}"
+msgstr "用户 [${id}]"
+
+#: ../templates/classic/html/user.item.html:22
+#: ../templates/minimal/html/user.item.html:14
+msgid "User${id} Editing"
+msgstr "用户 [${id}] 编辑"
+
+#: ../templates/classic/html/user.item.html:38
+#: ../templates/classic/html/user.register.html:21
+#: ../templates/minimal/html/user.item.html:27
+#: ../templates/minimal/html/user.item.html:67
+#: ../templates/minimal/html/user.register.html:26
+msgid "Login Name"
+msgstr "登录名"
+
+#: ../templates/classic/html/user.item.html:42
+#: ../templates/classic/html/user.register.html:25
+#: ../templates/minimal/html/user.item.html:31
+#: ../templates/minimal/html/user.register.html:30
+msgid "Login Password"
+msgstr "登录口令"
+
+#: ../templates/classic/html/user.item.html:46
+#: ../templates/classic/html/user.register.html:29
+#: ../templates/minimal/html/user.item.html:35
+#: ../templates/minimal/html/user.register.html:34
+msgid "Confirm Password"
+msgstr "口令确认"
+
+#: ../templates/classic/html/user.item.html:50
+#: ../templates/classic/html/user.register.html:33
+#: ../templates/minimal/html/user.item.html:39
+#: ../templates/minimal/html/user.register.html:38
+msgid "Roles"
+msgstr "角色"
+
+#: ../templates/classic/html/user.item.html:56
+msgid "(to give the user more than one role, enter a comma,separated,list)"
+msgstr "(为给用户指定多个角色,用逗号分隔它们)"
+
+#: ../templates/classic/html/user.item.html:61
+#: ../templates/classic/html/user.register.html:41
+msgid "Phone"
+msgstr "电话"
+
+#: ../templates/classic/html/user.item.html:69
+msgid "Timezone"
+msgstr "时区"
+
+#: ../templates/classic/html/user.item.html:73
+msgid "(this is a numeric hour offset, the default is ${zone})"
+msgstr "(这是数字的小时偏移量,缺省值是 ${zone})"
+
+#: ../templates/classic/html/user.item.html:78
+#: ../templates/classic/html/user.register.html:49
+#: ../templates/minimal/html/user.item.html:47
+#: ../templates/minimal/html/user.item.html:71
+#: ../templates/minimal/html/user.register.html:46
+msgid "E-mail address"
+msgstr "邮件地址"
+
+#: ../templates/classic/html/user.item.html:82
+#: ../templates/classic/html/user.register.html:53
+#: ../templates/minimal/html/user.item.html:51
+#: ../templates/minimal/html/user.register.html:50
+msgid "Alternate E-mail addresses<br>One address per line"
+msgstr "修改邮件地址<br>每行一个地址"
+
+#: ../templates/classic/html/user.register.html:4
+#: ../templates/classic/html/user.register.html:7
+#: ../templates/minimal/html/user.register.html:4
+#: ../templates/minimal/html/user.register.html:7
+msgid "Registering with ${tracker}"
+msgstr "用 ${tracker} 注册"
+
+#: ../templates/classic/html/user.rego_progress.html:4
+#: ../templates/minimal/html/user.rego_progress.html:4
+msgid "Registration in progress - ${tracker}"
+msgstr "注册正在处理 - ${tracker}"
+
+#: ../templates/classic/html/user.rego_progress.html:6
+#: ../templates/minimal/html/user.rego_progress.html:6
+msgid "Registration in progress..."
+msgstr "正在注册中..."
+
+#: ../templates/classic/html/user.rego_progress.html:10
+#: ../templates/minimal/html/user.rego_progress.html:10
+msgid "You will shortly receive an email to confirm your registration. To complete the registration process, visit the link indicated in the email."
+msgstr "你将很快收到一封确认信。为了完成注册过程,请访问邮件中指示的链接。"
+
+#: ../templates/minimal/html/home.html:2
+msgid "Tracker home - ${tracker}"
+msgstr "Tracker根目录 - ${tracker}"
+
+#: ../templates/minimal/html/home.html:4
+msgid "Tracker home"
+msgstr "Tracker根目录"
+
+#: ../templates/minimal/html/home.html:16
+msgid "Please select from one of the menu options on the left."
+msgstr "请在左侧的菜单选项中选择一项"
+
+#: ../templates/minimal/html/home.html:19
+msgid "Please log in or register."
+msgstr "请登录或注册。"
+
+#: ../templates/minimal/html/page.html:38
+msgid "Hello,<br>${user}"
+msgstr "你好,<br>${user}"
+
+#: ../templates/minimal/html/user.item.html:3
+msgid "User editing - ${tracker}"
+msgstr "用户编辑 - ${tracker}"
+

Added: tracker/vendor/roundup/current/locale/zh_TW.po
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/locale/zh_TW.po	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,2743 @@
+# Chinese Traditional message file for Roundup Issue Tracker
+# Fred Lin <gasolin at gmail.com>
+#
+# $Id: zh_TW.po,v 1.2 2005/05/16 09:31:48 a1s Exp $
+#
+# roundup.pot revision 1.10
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: 0.8.3\n"
+"Report-Msgid-Bugs-To: roundup-devel at lists.sourceforge.net\n"
+"POT-Creation-Date: 2004-10-19 12:33+0300\n"
+"PO-Revision-Date: 2005-05-15 17:40+0800\n"
+"Last-Translator: Fred Lin <gasolin at gmail>\n"
+"Language-Team: Chinese Traditional <gasolin at gmail.com>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=1; plural=0;\n"
+"X-Poedit-Language: Chinese\n"
+"X-Poedit-Country: TAIWAN\n"
+
+# ../roundup/admin.py:84 :943 :992 :1014
+#: ../roundup/admin.py:84
+#: ../roundup/admin.py:943
+#: ../roundup/admin.py:992
+#: ../roundup/admin.py:1014
+#, python-format
+msgid "no such class \"%(classname)s\""
+msgstr "無此類別 \"%(classname)s\""
+
+# ../roundup/admin.py:94 :98
+#: ../roundup/admin.py:94
+#: ../roundup/admin.py:98
+#, python-format
+msgid "argument \"%(arg)s\" not propname=value"
+msgstr "參數 \"%(arg)s\" 不是 propname=value 的形式"
+
+#: ../roundup/admin.py:111
+#, python-format
+msgid ""
+"Problem: %(message)s\n"
+"\n"
+msgstr ""
+"問題: %(message)s\n"
+"\n"
+
+#: ../roundup/admin.py:112
+#, python-format
+msgid ""
+"%(message)sUsage: roundup-admin [options] [<command> <arguments>]\n"
+"\n"
+"Options:\n"
+" -i instance home  -- specify the issue tracker \"home directory\" to administer\n"
+" -u                -- the user[:password] to use for commands\n"
+" -d                -- print full designators not just class id numbers\n"
+" -c                -- when outputting lists of data, comma-separate them.\n"
+"                      Same as '-S \",\"'.\n"
+" -S <string>       -- when outputting lists of data, string-separate them\n"
+" -s                -- when outputting lists of data, space-separate them.\n"
+"                      Same as '-S \" \"'.\n"
+"\n"
+" Only one of -s, -c or -S can be specified.\n"
+"\n"
+"Help:\n"
+" roundup-admin -h\n"
+" roundup-admin help                       -- this help\n"
+" roundup-admin help <command>             -- command-specific help\n"
+" roundup-admin help all                   -- all available help\n"
+msgstr ""
+"%(message)s用法: roundup-admin [options] [<command> <arguments>]\n"
+"\n"
+"選項:\n"
+" -i 實例路徑       -- 指定問題跟蹤系統 \"根目錄\" 為 管理員\n"
+" -u                -- user[:password] 用於命令中\n"
+" -d                --列印所有的指示信息而不只是類的ID號\n"
+" -c                -- 在輸出數據列表時,使用句號('.')分隔。\n"
+"                      如同執行 '-S \",\"'。\n"
+" -S <string>       -- 當輸出數據列表時,使用 string 分隔\n"
+" -s                -- 當輸出數據列表時,使用空格分隔。\n"
+"                      如同執行 '-S \" \"'。\n"
+"\n"
+" -s, -c 或者 -S 只能有一個被指定。\n"
+"\n"
+"說明:\n"
+" roundup-admin -h\n"
+" roundup-admin help                       -- 本說明\n"
+" roundup-admin help <command>             -- 命令詳解說明\n"
+" roundup-admin help all                   -- 所有可用的說明\n"
+
+#: ../roundup/admin.py:137
+msgid "Commands:"
+msgstr "命令:"
+
+#: ../roundup/admin.py:144
+msgid ""
+"Commands may be abbreviated as long as the abbreviation\n"
+"matches only one command, e.g. l == li == lis == list."
+msgstr ""
+"命令可以被縮寫,只要縮寫只有一個命令可以匹配上,\n"
+"如:l == li == lis == list."
+
+#: ../roundup/admin.py:174
+msgid ""
+"\n"
+"All commands (except help) require a tracker specifier. This is just\n"
+"the path to the roundup tracker you're working with. A roundup tracker\n"
+"is where roundup keeps the database and configuration file that defines\n"
+"an issue tracker. It may be thought of as the issue tracker's \"home\n"
+"directory\". It may be specified in the environment variable TRACKER_HOME\n"
+"or on the command line as \"-i tracker\".\n"
+"\n"
+"A designator is a classname and a nodeid concatenated, eg. bug1, user10, ...\n"
+"\n"
+"Property values are represented as strings in command arguments and in the\n"
+"printed results:\n"
+" . Strings are, well, strings.\n"
+" . Date values are printed in the full date format in the local time zone,\n"
+"   and accepted in the full format or any of the partial formats explained\n"
+"   below.\n"
+" . Link values are printed as node designators. When given as an argument,\n"
+"   node designators and key strings are both accepted.\n"
+" . Multilink values are printed as lists of node designators joined\n"
+"   by commas.  When given as an argument, node designators and key\n"
+"   strings are both accepted; an empty string, a single node, or a list\n"
+"   of nodes joined by commas is accepted.\n"
+"\n"
+"When property values must contain spaces, just surround the value with\n"
+"quotes, either ' or \". A single space may also be backslash-quoted. If a\n"
+"value must contain a quote character, it must be backslash-quoted or inside\n"
+"quotes. Examples:\n"
+"           hello world      (2 tokens: hello, world)\n"
+"           \"hello world\"    (1 token: hello world)\n"
+"           \"Roch'e\" Compaan (2 tokens: Roch'e Compaan)\n"
+"           Roch\\'e Compaan  (2 tokens: Roch'e Compaan)\n"
+"           address=\"1 2 3\"  (1 token: address=1 2 3)\n"
+"           \\\\               (1 token: \\)\n"
+"           \\n"
+"\\r\\t           (1 token: a newline, carriage-return and tab)\n"
+"\n"
+"When multiple nodes are specified to the roundup get or roundup set\n"
+"commands, the specified properties are retrieved or set on all the listed\n"
+"nodes.\n"
+"\n"
+"When multiple results are returned by the roundup get or roundup find\n"
+"commands, they are printed one per line (default) or joined by commas (with\n"
+"the -c) option.\n"
+"\n"
+"Where the command changes data, a login name/password is required. The\n"
+"login may be specified as either \"name\" or \"name:password\".\n"
+" . ROUNDUP_LOGIN environment variable\n"
+" . the -u command-line option\n"
+"If either the name or password is not supplied, they are obtained from the\n"
+"command-line.\n"
+"\n"
+"Date format examples:\n"
+"  \"2000-04-17.03:45\" means <Date 2000-04-17.08:45:00>\n"
+"  \"2000-04-17\" means <Date 2000-04-17.00:00:00>\n"
+"  \"01-25\" means <Date yyyy-01-25.00:00:00>\n"
+"  \"08-13.22:13\" means <Date yyyy-08-14.03:13:00>\n"
+"  \"11-07.09:32:43\" means <Date yyyy-11-07.14:32:43>\n"
+"  \"14:25\" means <Date yyyy-mm-dd.19:25:00>\n"
+"  \"8:47:11\" means <Date yyyy-mm-dd.13:47:11>\n"
+"  \".\" means \"right now\"\n"
+"\n"
+"Command help:\n"
+msgstr ""
+"\n"
+"所有的命令(除了 help)要求指定一個tracker。這就是你正在工作的tracker的路徑。\n"
+"一個tracker就是roundup維護的數據庫和定義了tracker配置文件的地方。可以把它想\n"
+"象為問題跟蹤系統的\"起始\"目錄。它可以在環境變量 TRACKER_HOME 或在命令行以 \n"
+"\"-i tracker\" 來指定。\n"
+"\n"
+"一個指示器(designator)是一個類名和一個結點id的結合體,如:bug1, user10, ...\n"
+"\n"
+"屬性值在命令參數中和列印結果中被描述為字符串:\n"
+" . Strings 表示字符串。\n"
+" . Date 的值在本地時區中按全日期格式列印,並且可以按全日期格式或下面解釋的任\n"
+"   何部分日期格式來接收。\n"
+" . Link 的值按結點指示器(designator)來列印。當作為參數給出時,結點指示器\n"
+"   (designator)和鍵字符串都可以接收。\n"
+" . Multilink 的值按結點指示器(designator)列表(以逗號分隔)來列印。當作為一個參\n"
+"   數給出時,結點指示器(designator)或以逗號聯接的結點列表都是可以接受的。\n"
+"\n"
+"當屬性值必須包含空格時,只需使用 ' 或者 \" 來包含值。單個空格也可以用反斜線來\n"
+"轉義。如果一個值必須包含引號字符,它必須使用反斜線來轉義或內部包含。例如:\n"
+"           hello world      (2 tokens: hello, world)\n"
+"           \"hello world\"    (1 token: hello world)\n"
+"           \"Roch'e\" Compaan (2 tokens: Roch'e Compaan)\n"
+"           Roch\\'e Compaan  (2 tokens: Roch'e Compaan)\n"
+"           address=\"1 2 3\"  (1 token: address=1 2 3)\n"
+"           \\\\               (1 token: \\)\n"
+"           \\n"
+"\\r\\t           (1 token: a newline, carriage-return and tab)\n"
+"\n"
+"當多個結點被指定用在 Roundup 的 get 或 set 命令時,指定的屬性在所有列出\n"
+"的結點上會被獲取或設置。\n"
+"\n"
+"當 Roundup 的 get 或 find 命令返回多個結果時,每行將列印一個屬性(預設)或\n"
+"用逗號聯接起來(用 -c 參數)。\n"
+"\n"
+"在存在修改數據的命令中,需要登錄名/口令。登錄名或者用 \"name\" 或 \"name:password\"\n"
+"來指定。\n"
+" . ROUNDUP_LOGIN 環境變量\n"
+" . -u 命令行選項\n"
+"如果名字或口令都沒有提供,它們將從命令行獲得。\n"
+"\n"
+"Date format examples:\n"
+"  \"2000-04-17.03:45\" 表示 <Date 2000-04-17.08:45:00>\n"
+"  \"2000-04-17\" 表示 <Date 2000-04-17.00:00:00>\n"
+"  \"01-25\" 表示 <Date yyyy-01-25.00:00:00>\n"
+"  \"08-13.22:13\" 表示 <Date yyyy-08-14.03:13:00>\n"
+"  \"11-07.09:32:43\" 表示 <Date yyyy-11-07.14:32:43>\n"
+"  \"14:25\" 表示 <Date yyyy-mm-dd.19:25:00>\n"
+"  \"8:47:11\" 表示 <Date yyyy-mm-dd.13:47:11>\n"
+"  \".\" 表示 \"現在\"\n"
+"\n"
+"使用說明:\n"
+
+#: ../roundup/admin.py:237
+#, python-format
+msgid "%s:"
+msgstr ""
+
+#: ../roundup/admin.py:242
+msgid ""
+"Usage: help topic\n"
+"        Give help about topic.\n"
+"\n"
+"        commands  -- list commands\n"
+"        <command> -- help specific to a command\n"
+"        initopts  -- init command options\n"
+"        all       -- all available help\n"
+"        "
+msgstr ""
+"用法:help topic\n"
+"        給出關於主題的說明。\n"
+"\n"
+"        commands  -- 列出命令\n"
+"        <command> -- 指定命令的說明規範\n"
+"        initopts  -- 初始化命令選項\n"
+"        all       -- 所有可用的說明\n"
+"        "
+
+#: ../roundup/admin.py:265
+#, python-format
+msgid "Sorry, no help for \"%(topic)s\""
+msgstr "抱歉,沒有對 \"%(topic)s\" 的說明信息"
+
+# ../roundup/admin.py:337 :387
+#: ../roundup/admin.py:337
+#: ../roundup/admin.py:387
+msgid "Templates:"
+msgstr "模板:"
+
+# ../roundup/admin.py:340 :398
+#: ../roundup/admin.py:340
+#: ../roundup/admin.py:398
+msgid "Back ends:"
+msgstr "後端:"
+
+#: ../roundup/admin.py:343
+msgid ""
+"Usage: install [template [backend [admin password]]]\n"
+"        Install a new Roundup tracker.\n"
+"\n"
+"        The command will prompt for the tracker home directory\n"
+"        (if not supplied through TRACKER_HOME or the -i option).\n"
+"        The template, backend and admin password may be specified\n"
+"        on the command-line as arguments, in that order.\n"
+"\n"
+"        The initialise command must be called after this command in order\n"
+"        to initialise the tracker's database. You may edit the tracker's\n"
+"        initial database contents before running that command by editing\n"
+"        the tracker's dbinit.py module init() function.\n"
+"\n"
+"        See also initopts help.\n"
+"        "
+msgstr ""
+"用法:install [template [backend [admin password]]]\n"
+"        安裝一個新的tracker實例。\n"
+"\n"
+"        這個命令將提示輸入 tracker 起始目錄\n"
+"        (如果沒有通過 TRACKER_HOME 或 -i 選項提供)。\n"
+"        模板、後端和管理員口令應該在命令行按順序以參數的形式被指定。\n"
+"\n"
+"        初始化(initialise)命令必須在這個命令之後被調用,以便初始化tracker數\n"
+"        據庫。你可以在運行初始化命令之前編輯 tracker 的 dbinit.py 模塊的\n"
+"        init() 方法來修改 tracker 的初始數據庫內容。\n"
+"\n"
+"        請查看初始化參數說明。\n"
+"        "
+
+# ../roundup/admin.py:359 :494 :573 :623 :676 :697 :725 :796 :863 :934 :982
+# :1004 :1031 :1093 :1159
+#: ../roundup/admin.py:359
+#: ../roundup/admin.py:494
+#: ../roundup/admin.py:573
+#: ../roundup/admin.py:623
+#: ../roundup/admin.py:676
+#: ../roundup/admin.py:697
+#: ../roundup/admin.py:725
+#: ../roundup/admin.py:796
+#: ../roundup/admin.py:863
+#: ../roundup/admin.py:934
+#: ../roundup/admin.py:982
+#: ../roundup/admin.py:1004
+#: ../roundup/admin.py:1031
+#: ../roundup/admin.py:1093
+#: ../roundup/admin.py:1159
+msgid "Not enough arguments supplied"
+msgstr "未提供足夠的參數"
+
+#: ../roundup/admin.py:365
+#, python-format
+msgid "Instance home parent directory \"%(parent)s\" does not exist"
+msgstr "實例目錄的父目錄 \"%(parent)s\" 不存在"
+
+#: ../roundup/admin.py:374
+#, python-format
+msgid ""
+"WARNING: There appears to be a tracker in \"%(tracker_home)s\"!\n"
+"If you re-install it, you will lose all the data!\n"
+"Erase it? Y/N: "
+msgstr ""
+"警告:在 \"%(tracker_home)s\" 已經存在一個tracker了!\n"
+"如果你打算重新安裝它,所有的數據將會丟失!\n"
+"刪除它嗎?Y/N: "
+
+#: ../roundup/admin.py:389
+msgid "Select template [classic]: "
+msgstr "選擇模板 [classic]:"
+
+#: ../roundup/admin.py:400
+msgid "Select backend [anydbm]: "
+msgstr "選擇後端 [anydbm]:"
+
+#: ../roundup/admin.py:409
+#, python-format
+msgid ""
+"\n"
+" You should now edit the tracker configuration file:\n"
+"   %(config_file)s"
+msgstr ""
+"\n"
+" 現在你應該修改tracker的配置文件:\n"
+"   %(config_file)s"
+
+#: ../roundup/admin.py:418
+msgid " ... at a minimum, you must set following options:"
+msgstr " ... 至少,你必須設置以下選項:"
+
+#: ../roundup/admin.py:423
+#, python-format
+msgid ""
+"\n"
+" If you wish to modify the database schema,\n"
+" you should also edit the schema file:\n"
+"   %(database_config_file)s\n"
+" You may also change the database initialisation file:\n"
+"   %(database_init_file)s\n"
+" ... see the documentation on customizing for more information.\n"
+msgstr ""
+"\n"
+" 如果你想要修改數據庫結構,\n"
+" 你也需要編輯表結構文件:\n"
+"   %(database_config_file)s\n"
+" 你可能也需要修改數據庫初始化文件:\n"
+"   %(database_init_file)s\n"
+" ... 查看關於客戶化的文檔來瞭解更多的信息。\n"
+
+#. password
+#: ../roundup/admin.py:438
+msgid ""
+"Usage: initialise [adminpw]\n"
+"        Initialise a new Roundup tracker.\n"
+"\n"
+"        The administrator details will be set at this step.\n"
+"\n"
+"        Execute the tracker's initialisation function dbinit.init()\n"
+"        "
+msgstr ""
+"用法:initialise [adminpw]\n"
+"        初始化一個新的tracker。\n"
+"\n"
+"        管理員的信息需要在這一步進行設置。\n"
+"\n"
+"        執行tracker的初始化函數 dbinit.init()\n"
+"        "
+
+#: ../roundup/admin.py:452
+msgid "Admin Password: "
+msgstr "管理員口令:"
+
+#: ../roundup/admin.py:453
+msgid "       Confirm: "
+msgstr "       確認:"
+
+#: ../roundup/admin.py:457
+msgid "Instance home does not exist"
+msgstr "實例目錄不存在"
+
+#: ../roundup/admin.py:461
+msgid "Instance has not been installed"
+msgstr "實例還沒有安裝"
+
+#: ../roundup/admin.py:466
+msgid ""
+"WARNING: The database is already initialised!\n"
+"If you re-initialise it, you will lose all the data!\n"
+"Erase it? Y/N: "
+msgstr ""
+"警告:數據庫已經被初始化!\n"
+"如果你重新初始化它,所有的數據將會丟失!\n"
+"刪除它嗎?Y/N: "
+
+#: ../roundup/admin.py:487
+msgid ""
+"Usage: get property designator[,designator]*\n"
+"        Get the given property of one or more designator(s).\n"
+"\n"
+"        Retrieves the property value of the nodes specified\n"
+"        by the designators.\n"
+"        "
+msgstr ""
+"用法:get property designator[,designator]*\n"
+"        得到指定屬性一個或多個指示器(designator)。\n"
+"\n"
+"        通過指示器(designator)來得到指定結點的屬性值。\n"
+"        "
+
+# ../roundup/admin.py:527 :542
+#: ../roundup/admin.py:527
+#: ../roundup/admin.py:542
+#, python-format
+msgid "property %s is not of type Multilink or Link so -d flag does not apply."
+msgstr "屬性 %s 不是 Multilink 或 Link 類型,所以 -d 標誌不能應用。"
+
+# ../roundup/admin.py:550 :945 :994 :1016
+#: ../roundup/admin.py:550
+#: ../roundup/admin.py:945
+#: ../roundup/admin.py:994
+#: ../roundup/admin.py:1016
+#, python-format
+msgid "no such %(classname)s node \"%(nodeid)s\""
+msgstr "沒有這樣的 %(classname)s 結點 \"%(nodeid)s\""
+
+#: ../roundup/admin.py:552
+#, python-format
+msgid "no such %(classname)s property \"%(propname)s\""
+msgstr "沒有這樣的 %(classname)s 屬性 \"%(propname)s\""
+
+#: ../roundup/admin.py:561
+msgid ""
+"Usage: set items property=value property=value ...\n"
+"        Set the given properties of one or more items(s).\n"
+"\n"
+"        The items are specified as a class or as a comma-separated\n"
+"        list of item designators (ie \"designator[,designator,...]\").\n"
+"\n"
+"        This command sets the properties to the values for all designators\n"
+"        given. If the value is missing (ie. \"property=\") then the property\n"
+"        is un-set. If the property is a multilink, you specify the linked\n"
+"        ids for the multilink as comma-separated numbers (ie \"1,2,3\").\n"
+"        "
+msgstr ""
+"用法:set items property=value property=value ...\n"
+"        設置一個或多個條目的屬性。\n"
+"\n"
+"        條目指的是一個類別,或以逗號分隔的項目指示器(designator)列表(例如:\"designator[,designator,...]\")。\n"
+"\n"
+"        這個命令為所有給出的指示器(designator)設置屬性值。如果屬性值被省略\n"
+"        (例如:\"property=\")那麼屬性是未設置的。如果屬性是一個多鏈接(multilink),\n"
+"        你需要為多鏈接提供用逗號分隔的數字(例如 \"1,2,3\")。\n"
+"        "
+
+#: ../roundup/admin.py:615
+msgid ""
+"Usage: find classname propname=value ...\n"
+"        Find the nodes of the given class with a given link property value.\n"
+"\n"
+"        Find the nodes of the given class with a given link property value.\n"
+"        The value may be either the nodeid of the linked node, or its key\n"
+"        value.\n"
+"        "
+msgstr ""
+"用法:find classname propname=value ...\n"
+"        根據給定的 link 屬性值來查找給定類型的結點。\n"
+"\n"
+"        根據給定的 link 屬性值來查找給定類型的結點。這個值或者是鏈接結點的結點ID,\n"
+"        或者是結點的鍵值。\n"
+"        "
+
+# ../roundup/admin.py:663 :816 :828 :882
+#: ../roundup/admin.py:663
+#: ../roundup/admin.py:816
+#: ../roundup/admin.py:828
+#: ../roundup/admin.py:882
+#, python-format
+msgid "%(classname)s has no property \"%(propname)s\""
+msgstr "%(classname)s 沒有 \"%(propname)s\" 屬性"
+
+#: ../roundup/admin.py:670
+msgid ""
+"Usage: specification classname\n"
+"        Show the properties for a classname.\n"
+"\n"
+"        This lists the properties for a given class.\n"
+"        "
+msgstr ""
+"用法: specification classname\n"
+"        顯示一個類型名的屬性。\n"
+"\n"
+"        會列出給定類型的屬性。\n"
+"        "
+
+#: ../roundup/admin.py:685
+#, python-format
+msgid "%(key)s: %(value)s (key property)"
+msgstr "%(key)s: %(value)s (關鍵屬性)"
+
+#: ../roundup/admin.py:687
+#, python-format
+msgid "%(key)s: %(value)s"
+msgstr ""
+
+#: ../roundup/admin.py:690
+msgid ""
+"Usage: display designator[,designator]*\n"
+"        Show the property values for the given node(s).\n"
+"\n"
+"        This lists the properties and their associated values for the given\n"
+"        node.\n"
+"        "
+msgstr ""
+"用法:display designator[,designator]*\n"
+"        顯示給出結點的屬性值。\n"
+"\n"
+"        將顯示給出結點的屬性和相應的值。\n"
+"        "
+
+#: ../roundup/admin.py:714
+#, python-format
+msgid "%(key)s: %(value)r"
+msgstr ""
+
+#: ../roundup/admin.py:717
+msgid ""
+"Usage: create classname property=value ...\n"
+"        Create a new entry of a given class.\n"
+"\n"
+"        This creates a new entry of the given class using the property\n"
+"        name=value arguments provided on the command line after the \"create\"\n"
+"        command.\n"
+"        "
+msgstr ""
+"用法:create classname property=value ...\n"
+"        建立一個給定類的新記錄。\n"
+"\n"
+"        建立一個給定類的新記錄,將使用 \"create\" 命令行後面的屬性 name=value 參數。\n"
+"        "
+
+#: ../roundup/admin.py:744
+#, python-format
+msgid "%(propname)s (Password): "
+msgstr "%(propname)s (口令):"
+
+#: ../roundup/admin.py:746
+#, python-format
+msgid "   %(propname)s (Again): "
+msgstr "   %(propname)s (再次):"
+
+#: ../roundup/admin.py:748
+msgid "Sorry, try again..."
+msgstr "抱歉,再試一次..."
+
+#: ../roundup/admin.py:752
+#, python-format
+msgid "%(propname)s (%(proptype)s): "
+msgstr ""
+
+#: ../roundup/admin.py:770
+#, python-format
+msgid "you must provide the \"%(propname)s\" property."
+msgstr "你必須提供 \"%(propname)s\" 屬性。"
+
+#: ../roundup/admin.py:781
+msgid ""
+"Usage: list classname [property]\n"
+"        List the instances of a class.\n"
+"\n"
+"        Lists all instances of the given class. If the property is not\n"
+"        specified, the  \"label\" property is used. The label property is\n"
+"        tried in order: the key, \"name\", \"title\" and then the first\n"
+"        property, alphabetically.\n"
+"\n"
+"        With -c, -S or -s print a list of item id's if no property\n"
+"        specified.  If property specified, print list of that property\n"
+"        for every class instance.\n"
+"        "
+msgstr ""
+"用法:list classname [property]\n"
+"        列出類型的實例。\n"
+"\n"
+"        列出所有給定類型的實例。如果屬性未被指定,則使用 \"label\" 屬性。\n"
+"        label 屬性以下列順序進行嘗試:鍵、\"name\"、\"title\" 和按字典順序\n"
+"        的第一個屬性。\n"
+"\n"
+"        如果沒有指定屬性,使用 -c, -S 或 -s 會列印出條目 id 的列表。如果指\n"
+"        定了屬性,對每個類型實例會列印出這個屬性。\n"
+"        "
+
+#: ../roundup/admin.py:794
+msgid "Too many arguments supplied"
+msgstr "提供了太多的參數了"
+
+#: ../roundup/admin.py:830
+#, python-format
+msgid "%(nodeid)4s: %(value)s"
+msgstr ""
+
+#: ../roundup/admin.py:834
+msgid ""
+"Usage: table classname [property[,property]*]\n"
+"        List the instances of a class in tabular form.\n"
+"\n"
+"        Lists all instances of the given class. If the properties are not\n"
+"        specified, all properties are displayed. By default, the column\n"
+"        widths are the width of the largest value. The width may be\n"
+"        explicitly defined by defining the property as \"name:width\".\n"
+"        For example::\n"
+"\n"
+"          roundup> table priority id,name:10\n"
+"          Id Name\n"
+"          1  fatal-bug\n"
+"          2  bug\n"
+"          3  usability\n"
+"          4  feature\n"
+"\n"
+"        Also to make the width of the column the width of the label,\n"
+"        leave a trailing : without a width on the property. For example::\n"
+"\n"
+"          roundup> table priority id,name:\n"
+"          Id Name\n"
+"          1  fata\n"
+"          2  bug\n"
+"          3  usab\n"
+"          4  feat\n"
+"\n"
+"        will result in a the 4 character wide \"Name\" column.\n"
+"        "
+msgstr ""
+"用法:table classname [property[,property]*]\n"
+"        以表格的表式列出類型的實例。\n"
+"\n"
+"        列出給定類型的所有實例。如果沒有指定屬性,所有屬性都會顯示出來。\n"
+"        預設情況下,列的寬度是最大值的寬度。這個寬度通過定義屬性為 \"name:width\"\n"
+"        被顯示地定義。例如:\n"
+"\n"
+"          roundup> table priority id,name:10\n"
+"          Id Name\n"
+"          1  fatal-bug\n"
+"          2  bug\n"
+"          3  usability\n"
+"          4  feature\n"
+"\n"
+"        也可以讓列的寬度為標籤的寬度,在屬性上沒有寬度值。例如:\n"
+"\n"
+"          roundup> table priority id,name:\n"
+"          Id Name\n"
+"          1  fata\n"
+"          2  bug\n"
+"          3  usab\n"
+"          4  feat\n"
+"\n"
+"        將生成4個字符寬的 \"Name\" 列。\n"
+"        "
+
+#: ../roundup/admin.py:878
+#, python-format
+msgid "\"%(spec)s\" not name:width"
+msgstr "\"%(spec)s\" 不是 名字:寬度"
+
+#: ../roundup/admin.py:928
+msgid ""
+"Usage: history designator\n"
+"        Show the history entries of a designator.\n"
+"\n"
+"        Lists the journal entries for the node identified by the designator.\n"
+"        "
+msgstr ""
+"用法:history designator\n"
+"        顯示指示器(designator)的歷史記錄。\n"
+"\n"
+"        顯示由指示器(designator)指明的結點的日誌記錄。\n"
+"        "
+
+#: ../roundup/admin.py:949
+msgid ""
+"Usage: commit\n"
+"        Commit changes made to the database during an interactive session.\n"
+"\n"
+"        The changes made during an interactive session are not\n"
+"        automatically written to the database - they must be committed\n"
+"        using this command.\n"
+"\n"
+"        One-off commands on the command-line are automatically committed if\n"
+"        they are successful.\n"
+"        "
+msgstr ""
+"用法:commit\n"
+"        提交在一個交互會話中所產生的改動。\n"
+"\n"
+"        在一個交互會話中所產生的改動不會自動寫入數據庫 - 它們必須使用此命令\n"
+"        來提交。\n"
+"        在命令行中的 One-off 命令如果成功會被自動提交。\n"
+"        "
+
+#: ../roundup/admin.py:963
+msgid ""
+"Usage: rollback\n"
+"        Undo all changes that are pending commit to the database.\n"
+"\n"
+"        The changes made during an interactive session are not\n"
+"        automatically written to the database - they must be committed\n"
+"        manually. This command undoes all those changes, so a commit\n"
+"        immediately after would make no changes to the database.\n"
+"        "
+msgstr ""
+"用法:rollback\n"
+"        撤銷所有未提交到數據庫的改動。\n"
+"\n"
+"        在交互對話中產生的改動並不自動寫到數據庫中 - 它們必須被手工提交。\n"
+"        這個命令用來撤銷所有這些改動,所以在後面跟上提交的話不會對數據庫\n"
+"        產生變化。\n"
+"        "
+
+#: ../roundup/admin.py:975
+msgid ""
+"Usage: retire designator[,designator]*\n"
+"        Retire the node specified by designator.\n"
+"\n"
+"        This action indicates that a particular node is not to be retrieved\n"
+"        by the list or find commands, and its key value may be re-used.\n"
+"        "
+msgstr ""
+"用法:retire designator[,designator]*\n"
+"        回收由指示器(designator)所指明的結點。\n"
+"\n"
+"        這個動作指明一個特別的結點將不能被 list 或 find 命令得到,並且\n"
+"        它的鍵值可以被重用。\n"
+"        "
+
+#: ../roundup/admin.py:998
+msgid ""
+"Usage: restore designator[,designator]*\n"
+"        Restore the retired node specified by designator.\n"
+"\n"
+"        The given nodes will become available for users again.\n"
+"        "
+msgstr ""
+"Usage: restore designator[,designator]*\n"
+"        恢復由指示器(designator)表明的已經回收的結點。\n"
+"\n"
+"        給定的結點將對用戶來說再次生效。\n"
+"        "
+
+#. grab the directory to export to
+#: ../roundup/admin.py:1020
+msgid ""
+"Usage: export [class[,class]] export_dir\n"
+"        Export the database to colon-separated-value files.\n"
+"\n"
+"        Optionally limit the export to just the names classes.\n"
+"\n"
+"        This action exports the current data from the database into\n"
+"        colon-separated-value files that are placed in the nominated\n"
+"        destination directory.\n"
+"        "
+msgstr ""
+"用法:export [class[,class]] export_dir\n"
+"        導出數據庫為冒號分隔值的文件。\n"
+"\n"
+"        對於導出的可選限制只是類名。\n"
+"\n"
+"        這個動作從數據庫中導出當前的數據到以冒號分隔值的文件中去,它們將存\n"
+"        放在指定的目標目錄中。\n"
+"        "
+
+#: ../roundup/admin.py:1073
+msgid ""
+"Usage: import import_dir\n"
+"        Import a database from the directory containing CSV files,\n"
+"        two per class to import.\n"
+"\n"
+"        The files used in the import are:\n"
+"\n"
+"        <class>.csv\n"
+"          This must define the same properties as the class (including\n"
+"          having a \"header\" line with those property names.)\n"
+"        <class>-journals.csv\n"
+"          This defines the journals for the items being imported.\n"
+"\n"
+"        The imported nodes will have the same nodeid as defined in the\n"
+"        import file, thus replacing any existing content.\n"
+"\n"
+"        The new nodes are added to the existing database - if you want to\n"
+"        create a new database using the imported data, then create a new\n"
+"        database (or, tediously, retire all the old data.)\n"
+"        "
+msgstr ""
+"用法:import import_dir\n"
+"        從包含 CSV 文件的目錄中導入數據庫,一個類有兩個文件用於導入。\n"
+"\n"
+"        用於導入的文件為:\n"
+"\n"
+"        <class>.csv\n"
+"          它必須定義與類型一樣的屬性(包括一個 \"header\" 行包含那些\n"
+"          屬性的名字。)\n"
+"        <class>-journals.csv\n"
+"          它用來定義被導入的條目的日誌。\n"
+"\n"
+"        被導入的結點將具與在導入文件中一樣的結點id,以便可以替換任何\n"
+"        任何已經存在的內容。\n"
+"        新結點被加入到已經存在的數據庫中 - 如果你想要使用導入數據來建\n"
+"        立一個新的數據庫,那麼創建一個新數據庫(或者,麻煩點,回收所有\n"
+"        舊數據。)\n"
+"        "
+
+#: ../roundup/admin.py:1141
+msgid ""
+"Usage: pack period | date\n"
+"\n"
+"        Remove journal entries older than a period of time specified or\n"
+"        before a certain date.\n"
+"\n"
+"        A period is specified using the suffixes \"y\", \"m\", and \"d\". The\n"
+"        suffix \"w\" (for \"week\") means 7 days.\n"
+"\n"
+"              \"3y\" means three years\n"
+"              \"2y 1m\" means two years and one month\n"
+"              \"1m 25d\" means one month and 25 days\n"
+"              \"2w 3d\" means two weeks and three days\n"
+"\n"
+"        Date format is \"YYYY-MM-DD\" eg:\n"
+"            2001-01-01\n"
+"\n"
+"        "
+msgstr ""
+"用法:pack period | date\n"
+"\n"
+"        刪除早於指定的時期或日期的舊的流水記錄。\n"
+"\n"
+"        一個時期使用後綴 \"y\", \"m\", 和 \"d\"。後綴 \"w\"(表示 \"week\")\n"
+"        表示 7 天。\n"
+"\n"
+"              \"3y\" 表示3年\n"
+"              \"2y 1m\" 表示2年1個月\n"
+"              \"1m 25d\" 表示1月25天\n"
+"              \"2w 3d\" 表示2周3天\n"
+"\n"
+"        日期格式是 \"YYYY-MM-DD\" 例如:\n"
+"            2001-01-01\n"
+"\n"
+"        "
+
+#: ../roundup/admin.py:1169
+msgid "Invalid format"
+msgstr "無效的格式"
+
+#: ../roundup/admin.py:1179
+msgid ""
+"Usage: reindex [classname|designator]*\n"
+"        Re-generate a tracker's search indexes.\n"
+"\n"
+"        This will re-generate the search indexes for a tracker.\n"
+"        This will typically happen automatically.\n"
+"        "
+msgstr ""
+"用法:reindex [classname|designator]*\n"
+"        重新生成 tracker 的搜索索引。\n"
+"\n"
+"        重新生成 tracker 的搜索索引,它將自動進行。\n"
+"        "
+
+#: ../roundup/admin.py:1193
+#, python-format
+msgid "no such item \"%(designator)s\""
+msgstr "沒有這樣的條目 \"%(designator)s\""
+
+#: ../roundup/admin.py:1203
+msgid ""
+"Usage: security [Role name]\n"
+"        Display the Permissions available to one or all Roles.\n"
+"        "
+msgstr ""
+"用法:security [角色名]\n"
+"        顯示一個或多個角色的權限。\n"
+"        "
+
+#: ../roundup/admin.py:1211
+#, python-format
+msgid "No such Role \"%(role)s\""
+msgstr "沒有這樣的角色 \"%(role)s\""
+
+#: ../roundup/admin.py:1217
+#, python-format
+msgid "New Web users get the Roles \"%(role)s\""
+msgstr "新Web用戶得到角色 \"%(role)s\""
+
+#: ../roundup/admin.py:1219
+#, python-format
+msgid "New Web users get the Role \"%(role)s\""
+msgstr "新Web用戶得到角色 \"%(role)s\""
+
+#: ../roundup/admin.py:1222
+#, python-format
+msgid "New Email users get the Roles \"%(role)s\""
+msgstr "新郵件用戶得到角色 \"%(role)s\""
+
+#: ../roundup/admin.py:1224
+#, python-format
+msgid "New Email users get the Role \"%(role)s\""
+msgstr "新郵件用戶得到角色 \"%(role)s\""
+
+#: ../roundup/admin.py:1227
+#, python-format
+msgid "Role \"%(name)s\":"
+msgstr "角色 \"%(name)s\":"
+
+#: ../roundup/admin.py:1230
+#, python-format
+msgid " %(description)s (%(name)s for \"%(klass)s\" only)"
+msgstr " %(description)s (%(name)s 僅用於 \"%(klass)s\")"
+
+#: ../roundup/admin.py:1233
+#, python-format
+msgid " %(description)s (%(name)s)"
+msgstr ""
+
+#: ../roundup/admin.py:1259
+#, python-format
+msgid "Unknown command \"%(command)s\" (\"help commands\" for a list)"
+msgstr "未知命令 \"%(command)s\" (\"help commands\" 查看命令列表)"
+
+#: ../roundup/admin.py:1265
+#, python-format
+msgid "Multiple commands match \"%(command)s\": %(list)s"
+msgstr "多命令匹配 \"%(command)s\": %(list)s"
+
+#: ../roundup/admin.py:1272
+msgid "Enter tracker home: "
+msgstr "輸入tracker起始目錄:"
+
+# ../roundup/admin.py:1279 :1285 :1305
+#: ../roundup/admin.py:1279
+#: ../roundup/admin.py:1285
+#: ../roundup/admin.py:1305
+#, python-format
+msgid "Error: %(message)s"
+msgstr "錯誤:%(message)s"
+
+#: ../roundup/admin.py:1293
+#, python-format
+msgid "Error: Couldn't open tracker: %(message)s"
+msgstr "錯誤:不能打開tracker:%(message)s"
+
+#: ../roundup/admin.py:1318
+#, python-format
+msgid ""
+"Roundup %s ready for input.\n"
+"Type \"help\" for help."
+msgstr ""
+"Roundup %s 輸入就緒。\n"
+"敲入 \"help\" 獲得說明。"
+
+#: ../roundup/admin.py:1323
+msgid "Note: command history and editing not available"
+msgstr "注意:命令歷史和編輯無效"
+
+#: ../roundup/admin.py:1327
+msgid "roundup> "
+msgstr ""
+
+#: ../roundup/admin.py:1329
+msgid "exit..."
+msgstr "退出..."
+
+#: ../roundup/admin.py:1339
+msgid "There are unsaved changes. Commit them (y/N)? "
+msgstr "存在未被保存的改動。提交嗎(y/N)?"
+
+#: ../roundup/backends/rdbms_common.py:1420
+msgid "create"
+msgstr "建立"
+
+#: ../roundup/backends/rdbms_common.py:1583
+msgid "unlink"
+msgstr "解除"
+
+#: ../roundup/backends/rdbms_common.py:1587
+msgid "link"
+msgstr "鏈接"
+
+#: ../roundup/backends/rdbms_common.py:1696
+msgid "set"
+msgstr "設置"
+
+#: ../roundup/backends/rdbms_common.py:1720
+msgid "retired"
+msgstr "收回"
+
+#: ../roundup/backends/rdbms_common.py:1750
+msgid "restored"
+msgstr "恢復"
+
+#: ../roundup/cgi/actions.py:53
+#, python-format
+msgid "You do not have permission to %(action)s the %(classname)s class."
+msgstr "你沒有權限來 %(action)s %(classname)s 類型。"
+
+#: ../roundup/cgi/actions.py:81
+msgid "No type specified"
+msgstr "沒有指定類型"
+
+#: ../roundup/cgi/actions.py:83
+msgid "No ID entered"
+msgstr "沒有輸入ID"
+
+#: ../roundup/cgi/actions.py:89
+#, python-format
+msgid "\"%(input)s\" is not an ID (%(classname)s ID required)"
+msgstr "\"%(input)s\" 不是一個 ID (要求 %(classname)s ID)"
+
+#: ../roundup/cgi/actions.py:109
+msgid "You may not retire the admin or anonymous user"
+msgstr "你不能刪除管理員或匿名用戶"
+
+#: ../roundup/cgi/actions.py:116
+#, python-format
+msgid "%(classname)s %(itemid)s has been retired"
+msgstr "%(classname)s %(itemid)s 已經被回收了"
+
+#: ../roundup/cgi/actions.py:271
+#, python-format
+msgid "Not enough values on line %(line)s"
+msgstr "在 %(line)s 行沒有足夠的值"
+
+#: ../roundup/cgi/actions.py:318
+msgid "Items edited OK"
+msgstr "項目編輯成功"
+
+#: ../roundup/cgi/actions.py:377
+#, python-format
+msgid "%(class)s %(id)s %(properties)s edited ok"
+msgstr "%(class)s %(id)s %(properties)s 編輯成功"
+
+#: ../roundup/cgi/actions.py:380
+#, python-format
+msgid "%(class)s %(id)s - nothing changed"
+msgstr "%(class)s %(id)s - 沒有改動"
+
+#: ../roundup/cgi/actions.py:392
+#, python-format
+msgid "%(class)s %(id)s created"
+msgstr "%(class)s %(id)s 被建立"
+
+#: ../roundup/cgi/actions.py:424
+#, python-format
+msgid "You do not have permission to edit %(class)s"
+msgstr "你沒有權限來編輯 %(class)s"
+
+#: ../roundup/cgi/actions.py:436
+#, python-format
+msgid "You do not have permission to create %(class)s"
+msgstr "你沒有權限來建立 %(class)s"
+
+#: ../roundup/cgi/actions.py:459
+msgid "You do not have permission to edit user roles"
+msgstr "你沒有編輯用戶或角色的權限"
+
+#: ../roundup/cgi/actions.py:518
+#, python-format
+msgid "Edit Error: %s"
+msgstr "編輯錯誤:%s"
+
+# ../roundup/cgi/actions.py:549 :559 :730 :749
+#: ../roundup/cgi/actions.py:549
+#: ../roundup/cgi/actions.py:559
+#: ../roundup/cgi/actions.py:730
+#: ../roundup/cgi/actions.py:749
+#, python-format
+msgid "Error: %s"
+msgstr "錯誤:%s"
+
+#: ../roundup/cgi/actions.py:585
+msgid ""
+"Invalid One Time Key!\n"
+"(a Mozilla bug may cause this message to show up erroneously, please check your email)"
+msgstr ""
+"Invalid One Time Key!\n"
+"(一個 Mozilla 的錯誤可能會錯誤地引發這個消息,你檢查你的郵件)"
+
+#: ../roundup/cgi/actions.py:627
+#, python-format
+msgid "Password reset and email sent to %s"
+msgstr "口令被重設,郵件被發給 %s"
+
+#: ../roundup/cgi/actions.py:636
+msgid "Unknown username"
+msgstr "未知用戶名"
+
+#: ../roundup/cgi/actions.py:644
+msgid "Unknown email address"
+msgstr "未知郵件地址"
+
+#: ../roundup/cgi/actions.py:649
+msgid "You need to specify a username or address"
+msgstr "你需要指定用戶名或地址"
+
+#: ../roundup/cgi/actions.py:674
+#, python-format
+msgid "Email sent to %s"
+msgstr "郵件發給 %s"
+
+#: ../roundup/cgi/actions.py:693
+msgid "You are now registered, welcome!"
+msgstr "你已經註冊,歡迎!"
+
+#: ../roundup/cgi/actions.py:738
+msgid "It is not permitted to supply roles at registration."
+msgstr "不允許在註冊時指供角色。"
+
+#: ../roundup/cgi/actions.py:820
+msgid "You are logged out"
+msgstr "你已經註銷"
+
+#: ../roundup/cgi/actions.py:831
+msgid "Username required"
+msgstr "需要用戶名"
+
+#: ../roundup/cgi/actions.py:846
+msgid "Ivalid login"
+msgstr "無效登錄"
+
+#: ../roundup/cgi/actions.py:853
+msgid "Invalid login"
+msgstr "無效登錄"
+
+#: ../roundup/cgi/actions.py:861
+msgid "You do not have permission to login"
+msgstr "你沒有登錄的權限"
+
+#: ../roundup/cgi/cgitb.py:49
+#, python-format
+msgid ""
+"<h1>Templating Error</h1>\n"
+"<p><b>%(exc_type)s</b>: %(exc_value)s</p>\n"
+"<p class=\"help\">Debugging information follows</p>"
+msgstr ""
+"<h1>模板錯誤</h1>\n"
+"<p><b>%(exc_type)s</b>: %(exc_value)s</p>\n"
+"<p class=\"help\">調試信息為</p>"
+
+#: ../roundup/cgi/cgitb.py:64
+#, python-format
+msgid "<li>\"%(name)s\" (%(info)s)</li>"
+msgstr ""
+
+#: ../roundup/cgi/cgitb.py:67
+#, python-format
+msgid "<li>Looking for \"%(name)s\", current path:<ol>%(path)s</ol></li>"
+msgstr "<li>查找 \"%(name)s\", 當前路徑:<ol>%(path)s</ol></li>"
+
+#: ../roundup/cgi/cgitb.py:71
+#, python-format
+msgid "<li>In %s</li>"
+msgstr "<li>在 %s</li>"
+
+#: ../roundup/cgi/cgitb.py:76
+#, python-format
+msgid "A problem occurred in your template \"%s\"."
+msgstr "在你的模板 \"%s\" 中發生一個問題。"
+
+#: ../roundup/cgi/cgitb.py:84
+#, python-format
+msgid ""
+"\n"
+"<li>While evaluating the %(info)r expression on line %(line)d\n"
+"<table class=\"otherinfo\" style=\"font-size: 90%%\">\n"
+" <tr><th colspan=\"2\" class=\"header\">Current variables:</th></tr>\n"
+" %(globals)s\n"
+" %(locals)s\n"
+"</table></li>\n"
+msgstr ""
+"\n"
+"<li>在 %(line)d 行計算 %(info)r 表達式\n"
+"<table class=\"otherinfo\" style=\"font-size: 90%%\">\n"
+" <tr><th colspan=\"2\" class=\"header\">當前變量:</th></tr>\n"
+" %(globals)s\n"
+" %(locals)s\n"
+"</table></li>\n"
+
+#: ../roundup/cgi/cgitb.py:103
+msgid "Full traceback:"
+msgstr "完整跟蹤信息:"
+
+#: ../roundup/cgi/cgitb.py:116
+#, python-format
+msgid "<font size=+1><strong>%(exc_type)s</strong>: %(exc_value)s</font>"
+msgstr ""
+
+#: ../roundup/cgi/cgitb.py:120
+msgid "<p>A problem occurred while running a Python script. Here is the sequence of function calls leading up to the error, with the most recent (innermost) call first. The exception attributes are:"
+msgstr "<p>在運行 Python 腳本時發生了一個錯誤。這是導致出錯的一系列的函數調用,最近的(最裡層的)調用在前。異常屬性是:"
+
+#: ../roundup/cgi/cgitb.py:129
+msgid "&lt;file is None - probably inside <tt>eval</tt> or <tt>exec</tt>&gt;"
+msgstr "&lt;文件為 None - 可能在 <tt>eval</tt> 或者 <tt>exec</tt>&gt;"
+
+#: ../roundup/cgi/cgitb.py:138
+#, python-format
+msgid "in <strong>%s</strong>"
+msgstr "在 <strong>%s</strong>"
+
+# ../roundup/cgi/cgitb.py:172 :178
+#: ../roundup/cgi/cgitb.py:172
+#: ../roundup/cgi/cgitb.py:178
+msgid "<em>undefined</em>"
+msgstr "<em>未定義</em>"
+
+#: ../roundup/cgi/client.py:273
+msgid "Form Error: "
+msgstr "表格錯誤:"
+
+#: ../roundup/cgi/client.py:323
+#, python-format
+msgid "Unrecognized charset: %r"
+msgstr "無法識別的字符集:%r"
+
+#: ../roundup/cgi/client.py:398
+msgid "Anonymous users are not allowed to use the web interface"
+msgstr "匿名用戶不允許使用web界面"
+
+#: ../roundup/cgi/form_parser.py:283
+#, python-format
+msgid "link \"%(key)s\" value \"%(value)s\" not a designator"
+msgstr "鏈接 \"%(key)s\" 的值 \"%(value)s\" 不是一個 指示器(designator)"
+
+#: ../roundup/cgi/form_parser.py:290
+#, python-format
+msgid "%(class)s %(property)s is not a link or multilink property"
+msgstr "%(class)s %(property)s 不是一個 Link 或 MultiLink 屬性"
+
+#: ../roundup/cgi/form_parser.py:312
+#, python-format
+msgid "You have submitted a %(action)s action for the property \"%(property)s\" which doesn't exist"
+msgstr "你提交了一個對於不存在屬性 \"%(property)s\" 的一個操作 %(action)s"
+
+# ../roundup/cgi/form_parser.py:331 :357
+#: ../roundup/cgi/form_parser.py:331
+#: ../roundup/cgi/form_parser.py:357
+#, python-format
+msgid "You have submitted more than one value for the %s property"
+msgstr "你需要提交針對 %s 屬性的一個以上的值"
+
+# ../roundup/cgi/form_parser.py:354 :360
+#: ../roundup/cgi/form_parser.py:354
+#: ../roundup/cgi/form_parser.py:360
+msgid "Password and confirmation text do not match"
+msgstr "口令和確認文本不匹配"
+
+#: ../roundup/cgi/form_parser.py:395
+#, python-format
+msgid "property \"%(propname)s\": \"%(value)s\" not currently in list"
+msgstr "屬性 \"%(propname)s\": \"%(value)s\" 當前不在列表中"
+
+#: ../roundup/cgi/form_parser.py:509
+#, python-format
+msgid "Required %(class)s property %(property)s not supplied"
+msgstr "要求的 %(class)s 屬性 %(property)s 沒有被提供"
+
+#: ../roundup/cgi/form_parser.py:529
+msgid "File is empty"
+msgstr "文件為空"
+
+#: ../roundup/cgi/templating.py:68
+#, python-format
+msgid "You are not allowed to %(action)s items of class %(class)s"
+msgstr "你不允許 %(action)s 類別 %(class)s 的項目"
+
+#: ../roundup/cgi/templating.py:598
+msgid "(list)"
+msgstr "(列表)"
+
+#: ../roundup/cgi/templating.py:632
+msgid "Submit New Entry"
+msgstr "提交新的項"
+
+#: ../roundup/cgi/templating.py:644
+msgid "New node - no history"
+msgstr "新記錄 - 無歷史"
+
+#: ../roundup/cgi/templating.py:744
+msgid "Submit Changes"
+msgstr "提交變動"
+
+#: ../roundup/cgi/templating.py:825
+msgid "<em>The indicated property no longer exists</em>"
+msgstr "<em>指示的屬性不再存在</em>"
+
+#: ../roundup/cgi/templating.py:826
+#, python-format
+msgid "<em>%s: %s</em>\n"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:839
+#, python-format
+msgid "The linked class %(classname)s no longer exists"
+msgstr "鏈接的類別 %(classname)s 不再存在"
+
+# ../roundup/cgi/templating.py:872 :893
+#: ../roundup/cgi/templating.py:872
+#: ../roundup/cgi/templating.py:893
+msgid "<strike>The linked node no longer exists</strike>"
+msgstr "<strike>鏈接的結點不再存在</strike>"
+
+#: ../roundup/cgi/templating.py:932
+msgid "No"
+msgstr "否"
+
+#: ../roundup/cgi/templating.py:932
+msgid "Yes"
+msgstr "是"
+
+#: ../roundup/cgi/templating.py:943
+#, python-format
+msgid "%s: (no value)"
+msgstr "%s: (無值)"
+
+#: ../roundup/cgi/templating.py:955
+msgid "<strong><em>This event is not handled by the history display!</em></strong>"
+msgstr "<strong><em>這個事件不能被歷史顯示所處理!</em></strong>"
+
+#: ../roundup/cgi/templating.py:967
+msgid "<tr><td colspan=4><strong>Note:</strong></td></tr>"
+msgstr "<tr><td colspan=4><strong>注意:</strong></td></tr>"
+
+#: ../roundup/cgi/templating.py:976
+msgid "History"
+msgstr "歷史"
+
+#: ../roundup/cgi/templating.py:978
+msgid "<th>Date</th>"
+msgstr "<th>日期</th>"
+
+#: ../roundup/cgi/templating.py:979
+msgid "<th>User</th>"
+msgstr "<th>用戶</th>"
+
+#: ../roundup/cgi/templating.py:980
+msgid "<th>Action</th>"
+msgstr "<th>動作</th>"
+
+#: ../roundup/cgi/templating.py:981
+msgid "<th>Args</th>"
+msgstr "<th>參數</th>"
+
+#: ../roundup/cgi/templating.py:1221
+msgid "*encrypted*"
+msgstr "*加密的*"
+
+#: ../roundup/cgi/templating.py:1386
+msgid "default value for DateHTMLProperty must be either DateHTMLProperty or string date representation."
+msgstr "DateHTMLProperty 的預設值或者是 DateHTMLProperty 或字符串的日期表示。"
+
+#: ../roundup/cgi/templating.py:1571
+#, python-format
+msgid "<option %svalue=\"-1\">- no selection -</option>"
+msgstr "<option %svalue=\"-1\">- 未選擇 -</option>"
+
+#: ../roundup/date.py:180
+#, python-format
+msgid "Not a date spec: %s"
+msgstr "不是日期格式:%s"
+
+#: ../roundup/date.py:231
+#, python-format
+msgid "%r not a date spec (%s)"
+msgstr "%r 不是日期格式 (%s)"
+
+#: ../roundup/date.py:522
+msgid "Not an interval spec: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS] [date spec]"
+msgstr "不是時間間隔規範:[+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS] [日期規範]"
+
+#: ../roundup/date.py:541
+msgid "Not an interval spec: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS]"
+msgstr "不是時間間隔規範:[+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS]"
+
+#: ../roundup/date.py:678
+#, python-format
+msgid "%(number)s year"
+msgstr "%(number)så¹´"
+
+#: ../roundup/date.py:682
+#, python-format
+msgid "%(number)s month"
+msgstr "%(number)s月"
+
+#: ../roundup/date.py:686
+#, python-format
+msgid "%(number)s week"
+msgstr "%(number)s周"
+
+#: ../roundup/date.py:690
+#, python-format
+msgid "%(number)s day"
+msgstr "%(number)s天"
+
+#: ../roundup/date.py:694
+msgid "tomorrow"
+msgstr "明天"
+
+#: ../roundup/date.py:696
+msgid "yesterday"
+msgstr "昨天"
+
+#: ../roundup/date.py:699
+#, python-format
+msgid "%(number)s hour"
+msgstr "%(number)s小時"
+
+#: ../roundup/date.py:703
+msgid "an hour"
+msgstr "1小時"
+
+#: ../roundup/date.py:705
+msgid "1 1/2 hours"
+msgstr "1個半小時"
+
+#: ../roundup/date.py:707
+#, python-format
+msgid "1 %(number)s/4 hours"
+msgstr "1 %(number)s/4 小時"
+
+#: ../roundup/date.py:711
+msgid "in a moment"
+msgstr "一會兒"
+
+#: ../roundup/date.py:713
+msgid "just now"
+msgstr "剛才"
+
+#: ../roundup/date.py:716
+msgid "1 minute"
+msgstr "1分鐘"
+
+#: ../roundup/date.py:719
+#, python-format
+msgid "%(number)s minute"
+msgstr "%(number)s分鐘"
+
+#: ../roundup/date.py:722
+msgid "1/2 an hour"
+msgstr "半小時"
+
+#: ../roundup/date.py:724
+#, python-format
+msgid "%(number)s/4 hour"
+msgstr "%(number)s/4 小時"
+
+#: ../roundup/date.py:728
+#, python-format
+msgid "%s ago"
+msgstr "%s 之前"
+
+#: ../roundup/date.py:730
+#, python-format
+msgid "in %s"
+msgstr "在 %s"
+
+#: ../roundup/roundupdb.py:130
+msgid "files"
+msgstr "文件"
+
+#: ../roundup/roundupdb.py:130
+msgid "messages"
+msgstr "信息"
+
+#: ../roundup/roundupdb.py:130
+msgid "nosy"
+msgstr "雜事"
+
+#: ../roundup/roundupdb.py:130
+msgid "superseder"
+msgstr "延期"
+
+#: ../roundup/roundupdb.py:130
+msgid "title"
+msgstr "標題"
+
+#: ../roundup/roundupdb.py:131
+msgid "assignedto"
+msgstr "分配給"
+
+#: ../roundup/roundupdb.py:131
+msgid "priority"
+msgstr "優先級"
+
+#: ../roundup/roundupdb.py:131
+msgid "status"
+msgstr "狀態"
+
+#: ../roundup/roundupdb.py:131
+msgid "topic"
+msgstr "主題"
+
+#: ../roundup/roundupdb.py:134
+msgid "activity"
+msgstr "活躍度"
+
+#. following properties are common for all hyperdb classes
+#. they are listed here to keep things in one place
+#: ../roundup/roundupdb.py:134
+msgid "actor"
+msgstr "執行人"
+
+#: ../roundup/roundupdb.py:134
+msgid "creation"
+msgstr "建立"
+
+#: ../roundup/roundupdb.py:134
+msgid "creator"
+msgstr "建立者"
+
+#: ../roundup/scripts/roundup_demo.py:32
+#, python-format
+msgid "Enter directory path to create demo tracker [%s]: "
+msgstr "輸入目錄來建立演示tracker [%s]:"
+
+#: ../roundup/scripts/roundup_mailgw.py:36
+#, python-format
+msgid ""
+"Usage: %(program)s [-v] [-c] [[-C class] -S field=value]* <instance home> [method]\n"
+"\n"
+"Options:\n"
+" -v: print version and exit\n"
+" -c: default class of item to create (else the tracker's MAIL_DEFAULT_CLASS)\n"
+" -C / -S: see below\n"
+"\n"
+"The roundup mail gateway may be called in one of four ways:\n"
+" . with an instance home as the only argument,\n"
+" . with both an instance home and a mail spool file,\n"
+" . with both an instance home and a POP/APOP server account, or\n"
+" . with both an instance home and a IMAP/IMAPS server account.\n"
+"\n"
+"It also supports optional -C and -S arguments that allows you to set a\n"
+"fields for a class created by the roundup-mailgw. The default class if\n"
+"not specified is msg, but the other classes: issue, file, user can\n"
+"also be used. The -S or --set options uses the same\n"
+"property=value[;property=value] notation accepted by the command line\n"
+"roundup command or the commands that can be given on the Subject line\n"
+"of an email message.\n"
+"\n"
+"It can let you set the type of the message on a per email address basis.\n"
+"\n"
+"PIPE:\n"
+" In the first case, the mail gateway reads a single message from the\n"
+" standard input and submits the message to the roundup.mailgw module.\n"
+"\n"
+"UNIX mailbox:\n"
+" In the second case, the gateway reads all messages from the mail spool\n"
+" file and submits each in turn to the roundup.mailgw module. The file is\n"
+" emptied once all messages have been successfully handled. The file is\n"
+" specified as:\n"
+"   mailbox /path/to/mailbox\n"
+"\n"
+"POP:\n"
+" In the third case, the gateway reads all messages from the POP server\n"
+" specified and submits each in turn to the roundup.mailgw module. The\n"
+" server is specified as:\n"
+"    pop username:password at server\n"
+" The username and password may be omitted:\n"
+"    pop username at server\n"
+"    pop server\n"
+" are both valid. The username and/or password will be prompted for if\n"
+" not supplied on the command-line.\n"
+"\n"
+"APOP:\n"
+" Same as POP, but using Authenticated POP:\n"
+"    apop username:password at server\n"
+"\n"
+"IMAP:\n"
+" Connect to an IMAP server. This supports the same notation as that of\n"
+" POP mail.\n"
+"    imap username:password at server\n"
+" It also allows you to specify a specific mailbox other than INBOX using\n"
+" this format:\n"
+"    imap username:password at server mailbox\n"
+"\n"
+"IMAPS:\n"
+" Connect to an IMAP server over ssl.\n"
+" This supports the same notation as IMAP.\n"
+"    imaps username:password at server [mailbox]\n"
+"\n"
+msgstr ""
+"用法:%(program)s [-v] [-c] [[-C class] -S field=value]* <instance home> [method]\n"
+"\n"
+"選項:\n"
+" -v: 列印版本並且退出\n"
+" -c: 用來建立條目的預設類型(其它是tracker的MAIL_DEFAULT_CLASS)\n"
+" -C / -S: 看下面\n"
+"\n"
+"Roundup 郵件網關會以四種方式被調用:\n"
+" . 實例起始目錄作為唯一參數,\n"
+" . 實例起始目錄和郵件脫機(spool)文件,\n"
+" . 實例起始目錄和 POP/APOP 服務器帳號,或者\n"
+" . 實例起始目錄和 IMAP/IMAPS 服務器帳號。\n"
+"\n"
+"也支持使用可選的 -C 或 -S 參數,它們允許你為roundup-mailgw所建立的類\n"
+"設置域。如果沒有指定,則預設的類是 msg,但是其它的類:issue, file, user\n"
+"也可以使用。-S 或 --set 選項使用 property=value[;property=value] 表示法,\n"
+"它們可以被 Roundup 命令的命令行或可以指定一封郵件信息標題行的命令所接受。\n"
+"\n"
+"它可以讓你給每封郵件設置信息的類型。\n"
+"\n"
+"PIPE:\n"
+" 在第一種方式下,郵件網關從標準輸入讀取單條信息,並將信息提交給 roundup.mailgw\n"
+" 模塊。\n"
+"\n"
+"UNIX mailbox:\n"
+" 在第二種方式下,網關從郵件脫機文件中讀取所有的信息,並按順序提交給\n"
+" roundup.mailgw 模塊。一旦所有信息被成功處理,文件被清空。這個文件被\n"
+" 指定為:\n"
+"   mailbox /path/to/mailbox\n"
+"\n"
+"POP:\n"
+" 在第三種方式下,網關從指定的 POP 服務器讀出所有信息,並按順序提交到\n"
+" roundup.mailgw 模塊。服務器被指定為:\n"
+"    pop username:password at server\n"
+" 用戶名和口令可以被省略:\n"
+"    pop username at server\n"
+"    pop server\n"
+" 都是有效的。如果沒有提供用戶名或口令都將在命令行被提示。\n"
+"\n"
+"APOP:\n"
+" 同 POP,但使用認證的 POP:\n"
+"    apop username:password at server\n"
+"\n"
+"IMAP:\n"
+" 聯接到 IMAP 服務器。它支持同 POP 郵件相同的寫法。\n"
+"    imap username:password at server\n"
+" 除了 INBOX 外還允許你指定一個特別的郵箱,\n"
+" 使用這種格式:    imap username:password at server mailbox\n"
+"\n"
+"IMAPS:\n"
+" 通過ssl聯接到 IMAP 服務器。\n"
+" 它支持同 IMAP 一樣的寫法。\n"
+"    imaps username:password at server [mailbox]\n"
+"\n"
+
+#: ../roundup/scripts/roundup_mailgw.py:147
+msgid "Error: not enough source specification information"
+msgstr "錯誤:沒有足夠的源協議資訊"
+
+#: ../roundup/scripts/roundup_mailgw.py:157
+msgid "Error: pop specification not valid"
+msgstr "錯誤:pop協議無效"
+
+#: ../roundup/scripts/roundup_mailgw.py:164
+msgid "Error: apop specification not valid"
+msgstr "錯誤:apop協議無效"
+
+#: ../roundup/scripts/roundup_mailgw.py:178
+msgid "Error: The source must be either \"mailbox\", \"pop\", \"apop\", \"imap\" or \"imaps\""
+msgstr "錯誤:源必須是 \"mailbox\", \"pop\", \"apop\", \"imap\" 或者 \"imaps\" 之一"
+
+#: ../roundup/scripts/roundup_server.py:106
+msgid ""
+"<html><head><title>Roundup trackers index</title></head>\n"
+"<body><h1>Roundup trackers index</h1><ol>\n"
+msgstr ""
+"<html><head><title>Roundup tracker 索引</title></head>\n"
+"<body><h1>Roundup tracker 索引</h1><ol>\n"
+
+#: ../roundup/scripts/roundup_server.py:217
+#, python-format
+msgid "Error: %s: %s"
+msgstr "錯誤:%s: %s"
+
+#: ../roundup/scripts/roundup_server.py:325
+msgid ""
+" -c <Command>  Windows Service options.\n"
+"               If you want to run the server as a Windows Service, you\n"
+"               must configure the rest of the options by changing the\n"
+"               constants of this program.  You will at least configure\n"
+"               one tracker in the TRACKER_HOMES variable.  This option\n"
+"               is mutually exclusive from the rest.  Typing\n"
+"               \"roundup-server -c help\" shows Windows Services\n"
+"               specifics."
+msgstr ""
+" -c <Command>  Windows 服務選項。\n"
+"               如果你想把server作為一個Windows服務來運行,你必須通過修改\n"
+"               這個程序的常量來配置此選項的其它內容。你至少需要在 TRACKER_HOMES\n"
+"               變量上配置一個tracker。這個選項與其經選項是互斥的。打入\n"
+"               \"roundup-server -c help\" 來瞭解Windows服務的規範。"
+
+#: ../roundup/scripts/roundup_server.py:334
+msgid ""
+" -u <UID>      runs the Roundup web server as this UID\n"
+" -g <GID>      runs the Roundup web server as this GID\n"
+" -d <PIDfile>  run the server in the background and write the server's PID\n"
+"               to the file indicated by PIDfile. The -l option *must* be\n"
+"               specified if -d is used."
+msgstr ""
+" -u <UID>      以這個 UID 來運行 Roundup web 服務器\n"
+" -g <GID>      以這個 GID 來運行 Roundup web 服務器\n"
+" -d <PIDfile>  在後台運行服務器,並且將服務器的 PID 寫入指定的 PIDFile 中去。\n"
+"               如果使用了 -d 選項,則 -l 選項 *必須* 要指定。"
+
+#: ../roundup/scripts/roundup_server.py:342
+#, python-format
+msgid ""
+"%(message)sUsage: roundup-server [options] [name=tracker home]*\n"
+"\n"
+"Options:\n"
+" -v            prints the Roundup version number and exits\n"
+" -C <fname>    use configuration file\n"
+" -n <name>     sets the host name of the Roundup web server instance\n"
+" -p <port>     sets the port to listen on (default: %(port)s)\n"
+" -l <fname>    log to the file indicated by fname instead of stderr/stdout\n"
+" -N            log client machine names instead of IP addresses (much slower)\n"
+"%(os_part)s\n"
+"\n"
+"Examples:\n"
+" roundup-server -C /opt/roundup/etc/roundup-server.ini\n"
+"\n"
+" roundup-server support=/var/spool/roundup-trackers/support\n"
+"\n"
+" roundup-server -d /var/run/roundup.pid -l /var/log/roundup.log \\\n"
+"     support=/var/spool/roundup-trackers/support\n"
+"\n"
+"Configuration file format:\n"
+"   See the \"admin_guide\" in the Roundup \"doc\" directory.\n"
+"\n"
+"How to use \"name=tracker home\":\n"
+"   These arguments set the tracker home(s) to use. The name is how the\n"
+"   tracker is identified in the URL (it's the first part of the URL path).\n"
+"   The tracker home is the directory that was identified when you did\n"
+"   \"roundup-admin init\". You may specify any number of these name=home\n"
+"   pairs on the command-line. Make sure the name part doesn't include\n"
+"   any url-unsafe characters like spaces, as these confuse IE.\n"
+msgstr ""
+"%(message)s用法:roundup-server [options] [name=tracker home]*\n"
+"\n"
+"選項:\n"
+" -v            列印 Roundup 的版本號並且退出\n"
+" -C <fname>    使用配置文件\n"
+" -n <name>     設置 Roundup web 服務器實例的主機名\n"
+" -p <port>     設置監聽端口(預設:%(port)s)\n"
+" -l <fname>    將日誌輸出到由 fname 指定的文件中去,而不是 標準錯誤/標準輸出\n"
+" -N            將客戶端機器的名字而不是IP地址記錄到日誌中去(可能會慢點)\n"
+"%(os_part)s\n"
+"\n"
+"舉例:\n"
+" roundup-server -C /opt/roundup/etc/roundup-server.ini\n"
+"\n"
+" roundup-server support=/var/spool/roundup-trackers/support\n"
+"\n"
+" roundup-server -d /var/run/roundup.pid -l /var/log/roundup.log \\\n"
+"     support=/var/spool/roundup-trackers/support\n"
+"\n"
+"配置文件格式:\n"
+"   查閱在Roundup \"doc\" 目錄下的 \"admin_guide\" 。\n"
+"\n"
+"如何使用 \"name=tracker home\":\n"
+"   這些參數用來設置要使用的tracker的起始目錄。name 會在URL中用來\n"
+"   定位tracker(它是 URL 路徑的第一部分)。tracker home 是在你執行\n"
+"   \"roundup-admin init\" 時所指定的目錄。你可以在命令行上指定任\n"
+"   意數量的 name=home 對。要確保 name 部分不能包括任何非url安全的\n"
+"   字符,像空格,因為它們會把IE搞亂。\n"
+
+#: ../roundup/scripts/roundup_server.py:418
+msgid "WARNING: ignoring \"-g\" argument, not root"
+msgstr "警告:忽略 \"-g\" 參數,不是 root"
+
+#: ../roundup/scripts/roundup_server.py:424
+msgid "Can't change groups - no grp module"
+msgstr "不能修改組 - 無 grp 模塊"
+
+#: ../roundup/scripts/roundup_server.py:433
+#, python-format
+msgid "Group %(group)s doesn't exist"
+msgstr "組 %(group)s 不存在"
+
+#: ../roundup/scripts/roundup_server.py:444
+msgid "Can't run as root!"
+msgstr "不能以 root 運行!"
+
+#: ../roundup/scripts/roundup_server.py:447
+msgid "WARNING: ignoring \"-u\" argument, not root"
+msgstr "警告:忽略 \"-u\" 參數,不是 root"
+
+#: ../roundup/scripts/roundup_server.py:452
+msgid "Can't change users - no pwd module"
+msgstr "不能修改用戶 - 無 pwd 模塊"
+
+#: ../roundup/scripts/roundup_server.py:461
+#, python-format
+msgid "User %(user)s doesn't exist"
+msgstr "用戶 %(user)s 不存在"
+
+#: ../roundup/scripts/roundup_server.py:575
+msgid "Instances must be name=home"
+msgstr "實例必須是 實例名=實例路徑"
+
+#: ../roundup/scripts/roundup_server.py:589
+#, python-format
+msgid "Configuration saved to %s"
+msgstr "配置保存到 %s"
+
+#: ../roundup/scripts/roundup_server.py:606
+#, python-format
+msgid "Unable to bind to port %s, port already in use."
+msgstr "無法綁定到端口 %s, 端口已經被佔用。"
+
+#: ../roundup/scripts/roundup_server.py:625
+msgid "Sorry, you can't run the server as a daemon on this Operating System"
+msgstr "抱歉,在這個操作系統上不能以守護進程的方式來運行服務"
+
+#: ../roundup/scripts/roundup_server.py:639
+#, python-format
+msgid "Roundup server started on %(HOST)s:%(PORT)s"
+msgstr "Roundup server å•Ÿå‹•æ–¼ %(HOST)s:%(PORT)s"
+
+#: ../templates/classic/html/_generic.collision.html:4
+#: ../templates/minimal/html/_generic.collision.html:4
+msgid "${class} Edit Collision - ${tracker}"
+msgstr "${class} 編輯衝突 - ${tracker}"
+
+#: ../templates/classic/html/_generic.collision.html:7
+#: ../templates/minimal/html/_generic.collision.html:7
+msgid "${class} Edit Collision"
+msgstr "${class} 編輯衝突"
+
+#: ../templates/classic/html/_generic.collision.html:14
+#: ../templates/minimal/html/_generic.collision.html:14
+msgid ""
+"\n"
+"  There has been a collision. Another user updated this node\n"
+"  while you were editing. Please <a href='${context}'>reload</a>\n"
+"  the node and review your edits.\n"
+msgstr ""
+"\n"
+"  存在衝突。另一個用戶在你編輯時更新了此條記錄。\n"
+"  請 <a href='${context}'>重新載入</a> 記錄查看你的編輯。\n"
+
+#: ../templates/classic/html/_generic.help.html:9
+#: ../templates/minimal/html/_generic.help.html:9
+msgid "${property} help - ${tracker}"
+msgstr "${property} 說明 - ${tracker}"
+
+#: ../templates/classic/html/_generic.help.html:30
+#: ../templates/minimal/html/_generic.help.html:30
+msgid " Cancel "
+msgstr "取消"
+
+#: ../templates/classic/html/_generic.help.html:33
+#: ../templates/minimal/html/_generic.help.html:33
+msgid " Apply "
+msgstr "應用"
+
+#: ../templates/classic/html/_generic.help.html:40
+#: ../templates/classic/html/issue.index.html:67
+#: ../templates/minimal/html/_generic.help.html:40
+msgid "&lt;&lt; previous"
+msgstr "&lt;&lt; 向上"
+
+#: ../templates/classic/html/_generic.help.html:50
+#: ../templates/classic/html/issue.index.html:75
+#: ../templates/minimal/html/_generic.help.html:50
+msgid "${start}..${end} out of ${total}"
+msgstr "${start}..${end} 全部 ${total}"
+
+#: ../templates/classic/html/_generic.help.html:54
+#: ../templates/classic/html/issue.index.html:78
+#: ../templates/minimal/html/_generic.help.html:54
+msgid "next &gt;&gt;"
+msgstr "向下 &gt;&gt;"
+
+#: ../templates/classic/html/_generic.index.html:6
+#: ../templates/classic/html/_generic.item.html:4
+#: ../templates/minimal/html/_generic.index.html:6
+#: ../templates/minimal/html/_generic.item.html:4
+msgid "${class} editing - ${tracker}"
+msgstr "${class} 編輯 - ${tracker}"
+
+#: ../templates/classic/html/_generic.index.html:9
+#: ../templates/classic/html/_generic.item.html:7
+#: ../templates/minimal/html/_generic.index.html:9
+#: ../templates/minimal/html/_generic.item.html:7
+msgid "${class} editing"
+msgstr "${class} 編輯"
+
+#: ../templates/classic/html/_generic.index.html:14
+#: ../templates/classic/html/_generic.item.html:12
+#: ../templates/classic/html/file.item.html:9
+#: ../templates/classic/html/issue.index.html:10
+#: ../templates/classic/html/user.index.html:9
+#: ../templates/minimal/html/_generic.index.html:14
+#: ../templates/minimal/html/_generic.item.html:12
+#: ../templates/minimal/html/user.index.html:9
+#: ../templates/minimal/html/user.item.html:18
+#: ../templates/minimal/html/user.register.html:14
+msgid "You are not allowed to view this page."
+msgstr "你不允許查看此頁"
+
+#: ../templates/classic/html/_generic.index.html:22
+#: ../templates/minimal/html/_generic.index.html:22
+msgid "<p class=\"form-help\"> You may edit the contents of the ${classname} class using this form. Commas, newlines and double quotes (\") must be handled delicately. You may include commas and newlines by enclosing the values in double-quotes (\"). Double quotes themselves must be quoted by doubling (\"\"). </p> <p class=\"form-help\"> Multilink properties have their multiple values colon (\":\") separated (... ,\"one:two:three\", ...) </p> <p class=\"form-help\"> Remove entries by deleting their line. Add new entries by appending them to the table - put an X in the id column. </p>"
+msgstr "<p class=\"form-help\"> 你可以使用這個表格來編輯 ${classname} 類別。 逗號,換行和雙引號(\")必須被小心處理。你可以在雙引號(\")中包含逗號和換行。雙引號本身必須被兩個(\"\")所包括。</p> <p class=\"form-help\"> Multilink 屬性有多個值,這些值用冒號(\":\")分隔(...,\"一:二:三\",...) </p> <p class=\"form-help\"> 通過刪除它們所在的行來刪除項。追加一條新記錄到表中 - 在 id 列置上一個 X 。</p>"
+
+#: ../templates/classic/html/_generic.index.html:44
+#: ../templates/minimal/html/_generic.index.html:44
+msgid "Edit Items"
+msgstr "編輯項目"
+
+#: ../templates/classic/html/file.index.html:4
+msgid "List of files - ${tracker}"
+msgstr "文件列表 - ${tracker}"
+
+#: ../templates/classic/html/file.index.html:5
+msgid "List of files"
+msgstr "文件列表"
+
+#: ../templates/classic/html/file.index.html:10
+msgid "Download"
+msgstr "下載"
+
+#: ../templates/classic/html/file.index.html:11
+#: ../templates/classic/html/file.item.html:23
+#: ../templates/classic/html/file.item.html:51
+msgid "Content Type"
+msgstr "內容類型"
+
+#: ../templates/classic/html/file.index.html:12
+msgid "Uploaded By"
+msgstr "上傳由"
+
+#: ../templates/classic/html/file.index.html:13
+#: ../templates/classic/html/msg.item.html:38
+msgid "Date"
+msgstr "日期"
+
+#: ../templates/classic/html/file.item.html:2
+msgid "File display - ${tracker}"
+msgstr "文件顯示 - ${tracker}"
+
+#: ../templates/classic/html/file.item.html:4
+msgid "File display"
+msgstr "文件顯示"
+
+#: ../templates/classic/html/file.item.html:19
+#: ../templates/classic/html/file.item.html:47
+#: ../templates/classic/html/user.item.html:34
+#: ../templates/classic/html/user.register.html:17
+msgid "Name"
+msgstr "姓名"
+
+#: ../templates/classic/html/file.item.html:41
+msgid "download"
+msgstr "下載"
+
+#: ../templates/classic/html/home.classlist.html:2
+#: ../templates/minimal/html/home.classlist.html:2
+msgid "List of classes - ${tracker}"
+msgstr "類別列表 - ${tracker}"
+
+#: ../templates/classic/html/home.classlist.html:4
+#: ../templates/minimal/html/home.classlist.html:4
+msgid "List of classes"
+msgstr "類別列表"
+
+#: ../templates/classic/html/issue.index.html:4
+msgid "List of issues - ${tracker}"
+msgstr "問題列表 - ${tracker}"
+
+#: ../templates/classic/html/issue.index.html:6
+msgid "List of issues"
+msgstr "問題列表"
+
+#: ../templates/classic/html/issue.index.html:17
+#: ../templates/classic/html/issue.item.html:38
+msgid "Priority"
+msgstr "優先級"
+
+#: ../templates/classic/html/issue.index.html:18
+msgid "ID"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:19
+msgid "Creation"
+msgstr "建立時間"
+
+#: ../templates/classic/html/issue.index.html:20
+msgid "Activity"
+msgstr "活躍度"
+
+#: ../templates/classic/html/issue.index.html:21
+msgid "Actor"
+msgstr "執行者"
+
+#: ../templates/classic/html/issue.index.html:22
+msgid "Topic"
+msgstr "主題"
+
+#: ../templates/classic/html/issue.index.html:23
+#: ../templates/classic/html/issue.item.html:33
+msgid "Title"
+msgstr "標題"
+
+#: ../templates/classic/html/issue.index.html:24
+#: ../templates/classic/html/issue.item.html:40
+msgid "Status"
+msgstr "狀態"
+
+#: ../templates/classic/html/issue.index.html:25
+msgid "Creator"
+msgstr "建立者"
+
+#: ../templates/classic/html/issue.index.html:26
+msgid "Assigned&nbsp;To"
+msgstr "分配給"
+
+#: ../templates/classic/html/issue.index.html:90
+msgid "Download as CSV"
+msgstr "以CSV格式下載"
+
+#: ../templates/classic/html/issue.index.html:98
+msgid "Sort on:"
+msgstr "排序按:"
+
+#: ../templates/classic/html/issue.index.html:101
+#: ../templates/classic/html/issue.index.html:118
+msgid "- nothing -"
+msgstr "- ç„¡ -"
+
+#: ../templates/classic/html/issue.index.html:109
+#: ../templates/classic/html/issue.index.html:126
+msgid "Descending:"
+msgstr "降序:"
+
+#: ../templates/classic/html/issue.index.html:115
+msgid "Group on:"
+msgstr "分組:"
+
+#: ../templates/classic/html/issue.index.html:132
+msgid "Redisplay"
+msgstr "刷新"
+
+#: ../templates/classic/html/issue.item.html:7
+msgid "Issue ${id}: ${title} - ${tracker}"
+msgstr "問題 ${id}: ${title} - ${tracker}"
+
+#: ../templates/classic/html/issue.item.html:10
+msgid "New Issue - ${tracker}"
+msgstr "新問題 - ${tracker}"
+
+#: ../templates/classic/html/issue.item.html:14
+msgid "New Issue"
+msgstr "新問題"
+
+#: ../templates/classic/html/issue.item.html:16
+msgid "New Issue Editing"
+msgstr "新問題編輯"
+
+#: ../templates/classic/html/issue.item.html:19
+msgid "Issue${id}"
+msgstr "問題 [${id}]"
+
+#: ../templates/classic/html/issue.item.html:22
+msgid "Issue${id} Editing"
+msgstr "問題 [${id}] 編輯"
+
+#: ../templates/classic/html/issue.item.html:45
+msgid "Superseder"
+msgstr "推遲"
+
+#: ../templates/classic/html/issue.item.html:50
+msgid "View: ${link}"
+msgstr "查看:${link}"
+
+#: ../templates/classic/html/issue.item.html:54
+msgid "Nosy List"
+msgstr "雜事列表"
+
+#: ../templates/classic/html/issue.item.html:63
+msgid "Assigned To"
+msgstr "分配給"
+
+#: ../templates/classic/html/issue.item.html:65
+msgid "Topics"
+msgstr "主題"
+
+#: ../templates/classic/html/issue.item.html:73
+msgid "Change Note"
+msgstr "修改記錄"
+
+#: ../templates/classic/html/issue.item.html:81
+msgid "File"
+msgstr "文件"
+
+#: ../templates/classic/html/issue.item.html:100
+msgid "<table class=\"form\"> <tr> <td>Note:&nbsp;</td> <th class=\"required\">highlighted</th> <td>&nbsp;fields are required.</td> </tr> </table>"
+msgstr "<table class=\"form\"> <tr> <td>注意:&nbsp;</td> <th class=\"required\">高亮</th> <td>&nbsp;字段是必須的。</td> </tr> </table>"
+
+#: ../templates/classic/html/issue.item.html:114
+msgid "Created on <b>${creation}</b> by <b>${creator}</b>, last changed <b>${activity}</b> by <b>${actor}</b>."
+msgstr "在 <b>${creation}</b> 由 <b>${creator}</b> 建立,最後由 <b>${actor}</b> 修改為 <b>${activity}</b>。"
+
+#: ../templates/classic/html/issue.item.html:118
+#: ../templates/classic/html/msg.item.html:51
+msgid "Files"
+msgstr "文件"
+
+#: ../templates/classic/html/issue.item.html:120
+#: ../templates/classic/html/msg.item.html:53
+msgid "File name"
+msgstr "文件名"
+
+#: ../templates/classic/html/issue.item.html:121
+#: ../templates/classic/html/msg.item.html:54
+msgid "Uploaded"
+msgstr "已上傳"
+
+#: ../templates/classic/html/issue.item.html:122
+msgid "Type"
+msgstr "é¡žåž‹"
+
+#: ../templates/classic/html/issue.item.html:123
+#: ../templates/classic/html/query.edit.html:30
+msgid "Edit"
+msgstr "編輯"
+
+#: ../templates/classic/html/issue.item.html:124
+msgid "Remove"
+msgstr "刪除"
+
+#: ../templates/classic/html/issue.item.html:144
+#: ../templates/classic/html/issue.item.html:165
+#: ../templates/classic/html/query.edit.html:50
+msgid "remove"
+msgstr "刪除"
+
+#: ../templates/classic/html/issue.item.html:151
+#: ../templates/classic/html/msg.index.html:9
+msgid "Messages"
+msgstr "信息"
+
+#: ../templates/classic/html/issue.item.html:155
+msgid "msg${id} (view)"
+msgstr "msg${id} (查看)"
+
+#: ../templates/classic/html/issue.item.html:156
+msgid "Author: ${author}"
+msgstr "作者:${author}"
+
+#: ../templates/classic/html/issue.item.html:158
+msgid "Date: ${date}"
+msgstr "日期:${date}"
+
+#: ../templates/classic/html/issue.search.html:2
+msgid "Issue searching - ${tracker}"
+msgstr "問題搜索 - ${tracker}"
+
+#: ../templates/classic/html/issue.search.html:4
+msgid "Issue searching"
+msgstr "問題搜索"
+
+#: ../templates/classic/html/issue.search.html:25
+msgid "Filter on"
+msgstr "過濾按"
+
+#: ../templates/classic/html/issue.search.html:26
+msgid "Display"
+msgstr "顯示"
+
+#: ../templates/classic/html/issue.search.html:27
+msgid "Sort on"
+msgstr "排序按 "
+
+#: ../templates/classic/html/issue.search.html:28
+msgid "Group on"
+msgstr "分組按"
+
+#: ../templates/classic/html/issue.search.html:32
+msgid "All text*:"
+msgstr "所有文本*"
+
+#: ../templates/classic/html/issue.search.html:40
+msgid "Title:"
+msgstr "標題:"
+
+#: ../templates/classic/html/issue.search.html:50
+msgid "Topic:"
+msgstr "主題:"
+
+#: ../templates/classic/html/issue.search.html:58
+msgid "ID:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:66
+msgid "Creation Date:"
+msgstr "建立時間:"
+
+#: ../templates/classic/html/issue.search.html:77
+msgid "Creator:"
+msgstr "建立者:"
+
+#: ../templates/classic/html/issue.search.html:79
+msgid "created by me"
+msgstr "由我建立"
+
+#: ../templates/classic/html/issue.search.html:88
+msgid "Activity:"
+msgstr "活躍度:"
+
+#: ../templates/classic/html/issue.search.html:99
+msgid "Actor:"
+msgstr "執行人:"
+
+#: ../templates/classic/html/issue.search.html:101
+msgid "done by me"
+msgstr "由我完成"
+
+#: ../templates/classic/html/issue.search.html:112
+msgid "Priority:"
+msgstr "優先級:"
+
+#: ../templates/classic/html/issue.search.html:114
+#: ../templates/classic/html/issue.search.html:130
+msgid "not selected"
+msgstr "未選擇"
+
+#: ../templates/classic/html/issue.search.html:125
+msgid "Status:"
+msgstr "狀態:"
+
+#: ../templates/classic/html/issue.search.html:128
+msgid "not resolved"
+msgstr "未解決"
+
+#: ../templates/classic/html/issue.search.html:143
+msgid "Assigned to:"
+msgstr "分配給:"
+
+#: ../templates/classic/html/issue.search.html:146
+msgid "assigned to me"
+msgstr "分配給我"
+
+#: ../templates/classic/html/issue.search.html:148
+msgid "unassigned"
+msgstr "未分配"
+
+#: ../templates/classic/html/issue.search.html:158
+msgid "Pagesize:"
+msgstr "頁大小:"
+
+#: ../templates/classic/html/issue.search.html:164
+msgid "Start With:"
+msgstr "開始在:"
+
+#: ../templates/classic/html/issue.search.html:170
+msgid "Sort Descending:"
+msgstr "降序排列:"
+
+#: ../templates/classic/html/issue.search.html:177
+msgid "Group Descending:"
+msgstr "降序分組:"
+
+#: ../templates/classic/html/issue.search.html:184
+msgid "Query name**:"
+msgstr "查詢 名字**"
+
+#: ../templates/classic/html/issue.search.html:194
+#: ../templates/classic/html/page.html:47
+msgid "Search"
+msgstr "搜索"
+
+#: ../templates/classic/html/issue.search.html:198
+msgid "*: The \"all text\" field will look in message bodies and issue titles<br> **: If you supply a name, the query will be saved off and available as a link in the sidebar"
+msgstr "*: 在信息體和問題標題上的 \"所有的文本\" 字段都將被查找<br> **: 如果你提供了一個名字,這個查詢將被保存並且作為一個鏈接出現在側邊欄上"
+
+#: ../templates/classic/html/keyword.item.html:3
+msgid "Keyword editing - ${tracker}"
+msgstr "關鍵字編輯 - ${tracker}"
+
+#: ../templates/classic/html/keyword.item.html:5
+msgid "Keyword editing"
+msgstr "關鍵字編輯"
+
+#: ../templates/classic/html/keyword.item.html:11
+msgid "Existing Keywords"
+msgstr "存在的關鍵字"
+
+#: ../templates/classic/html/keyword.item.html:20
+msgid "To edit an existing keyword (for spelling or typing errors), click on its entry above."
+msgstr "為編輯一個存在的關鍵字(由於拼寫或打字錯誤),在上面的項目上點擊。"
+
+#: ../templates/classic/html/keyword.item.html:27
+msgid "To create a new keyword, enter it below and click \"Submit New Entry\"."
+msgstr "想要建立新的關鍵字,請點擊下面的 \"提交新的項\"。"
+
+#: ../templates/classic/html/keyword.item.html:37
+msgid "Keyword"
+msgstr "關鍵字"
+
+#: ../templates/classic/html/msg.index.html:3
+msgid "List of messages - ${tracker}"
+msgstr "信息列表 - ${tracker}"
+
+#: ../templates/classic/html/msg.index.html:5
+msgid "Message listing"
+msgstr "信息列表"
+
+#: ../templates/classic/html/msg.item.html:6
+msgid "Message ${id} - ${tracker}"
+msgstr "信息 [${id}] - ${tracker}"
+
+#: ../templates/classic/html/msg.item.html:9
+msgid "New Message - ${tracker}"
+msgstr "新信息 - ${tracker}"
+
+#: ../templates/classic/html/msg.item.html:13
+msgid "New Message"
+msgstr "新信息"
+
+#: ../templates/classic/html/msg.item.html:15
+msgid "New Message Editing"
+msgstr "新信息編輯"
+
+#: ../templates/classic/html/msg.item.html:18
+msgid "Message${id}"
+msgstr "信息 [${id}]"
+
+#: ../templates/classic/html/msg.item.html:21
+msgid "Message${id} Editing"
+msgstr "信息 [${id}] 編輯"
+
+#: ../templates/classic/html/msg.item.html:28
+msgid "Author"
+msgstr "作者"
+
+#: ../templates/classic/html/msg.item.html:33
+msgid "Recipients"
+msgstr "收信人"
+
+#: ../templates/classic/html/msg.item.html:44
+msgid "Content"
+msgstr "內容"
+
+#: ../templates/classic/html/page.html:28
+msgid "<b>Your Queries</b> (<a href=\"query?@template=edit\">edit</a>)"
+msgstr "<b>我的查詢</b> (<a href=\"query?@template=edit\">編輯</a>)"
+
+#: ../templates/classic/html/page.html:39
+msgid "Issues"
+msgstr "問題"
+
+#: ../templates/classic/html/page.html:41
+#: ../templates/classic/html/page.html:60
+msgid "Create New"
+msgstr "新建"
+
+#: ../templates/classic/html/page.html:43
+msgid "Show Unassigned"
+msgstr "顯示未分配"
+
+#: ../templates/classic/html/page.html:45
+msgid "Show All"
+msgstr "顯示所有"
+
+#: ../templates/classic/html/page.html:48
+msgid "Show issue:"
+msgstr "顯示問題:"
+
+#: ../templates/classic/html/page.html:58
+msgid "Keywords"
+msgstr "關鍵字"
+
+#: ../templates/classic/html/page.html:64
+msgid "Edit Existing"
+msgstr "編輯已經存在的"
+
+#: ../templates/classic/html/page.html:70
+#: ../templates/minimal/html/page.html:48
+msgid "Administration"
+msgstr "管理"
+
+#: ../templates/classic/html/page.html:72
+#: ../templates/minimal/html/page.html:49
+msgid "Class List"
+msgstr "類別列表"
+
+#: ../templates/classic/html/page.html:76
+#: ../templates/minimal/html/page.html:51
+msgid "User List"
+msgstr "用戶列表"
+
+#: ../templates/classic/html/page.html:78
+#: ../templates/minimal/html/page.html:54
+msgid "Add User"
+msgstr "增加用戶"
+
+#: ../templates/classic/html/page.html:85
+#: ../templates/classic/html/page.html:89
+#: ../templates/minimal/html/page.html:30
+msgid "Login"
+msgstr "登錄"
+
+#: ../templates/classic/html/page.html:91
+#: ../templates/classic/html/user.register.html:63
+#: ../templates/minimal/html/page.html:33
+#: ../templates/minimal/html/user.register.html:58
+msgid "Register"
+msgstr "註冊"
+
+#: ../templates/classic/html/page.html:94
+msgid "Lost&nbsp;your&nbsp;login?"
+msgstr "忘記你的登入口令了?"
+
+#: ../templates/classic/html/page.html:99
+msgid "Hello, ${user}"
+msgstr "追蹤,${user}"
+
+#: ../templates/classic/html/page.html:101
+msgid "Your Issues"
+msgstr "我的問題列表"
+
+#: ../templates/classic/html/page.html:102
+#: ../templates/minimal/html/page.html:40
+msgid "Your Details"
+msgstr "我的資訊"
+
+#: ../templates/classic/html/page.html:104
+#: ../templates/minimal/html/page.html:42
+msgid "Logout"
+msgstr "註銷"
+
+#: ../templates/classic/html/page.html:108
+msgid "Help"
+msgstr "說明"
+
+#: ../templates/classic/html/page.html:109
+msgid "Roundup docs"
+msgstr "Roundup文檔"
+
+#: ../templates/classic/html/page.html:160
+msgid "don't care"
+msgstr "不用關心"
+
+#: ../templates/classic/html/page.html:162
+msgid "------------"
+msgstr ""
+
+#: ../templates/classic/html/page.html:187
+msgid "no value"
+msgstr "無值"
+
+#: ../templates/classic/html/query.edit.html:4
+msgid "\"Your Queries\" Editing - ${tracker}"
+msgstr "\"我的查詢\" 修改 - ${tracker}"
+
+#: ../templates/classic/html/query.edit.html:6
+msgid "\"Your Queries\" Editing"
+msgstr "\"我的查詢\"修改"
+
+#: ../templates/classic/html/query.edit.html:11
+msgid "You are not allowed to edit queries."
+msgstr "不允許編輯查詢"
+
+#: ../templates/classic/html/query.edit.html:28
+msgid "Query"
+msgstr "查詢"
+
+#: ../templates/classic/html/query.edit.html:29
+msgid "Include in \"Your Queries\""
+msgstr "包括在\"我的查詢\"中"
+
+#: ../templates/classic/html/query.edit.html:31
+msgid "Private to you?"
+msgstr "是私人信息嗎?"
+
+#: ../templates/classic/html/query.edit.html:44
+msgid "leave out"
+msgstr "省略"
+
+#: ../templates/classic/html/query.edit.html:45
+msgid "include"
+msgstr "包含"
+
+#: ../templates/classic/html/query.edit.html:49
+msgid "leave in"
+msgstr "留下"
+
+#: ../templates/classic/html/query.edit.html:54
+msgid "[query is retired]"
+msgstr "[查詢過期了]"
+
+#: ../templates/classic/html/query.edit.html:67
+msgid "edit"
+msgstr "編輯"
+
+#: ../templates/classic/html/query.edit.html:71
+msgid "yes"
+msgstr "是"
+
+#: ../templates/classic/html/query.edit.html:73
+msgid "no"
+msgstr "否"
+
+#: ../templates/classic/html/query.edit.html:79
+msgid "Delete"
+msgstr "刪除"
+
+#: ../templates/classic/html/query.edit.html:90
+msgid "[not yours to edit]"
+msgstr "[不由你修改]"
+
+#: ../templates/classic/html/query.edit.html:96
+msgid "Save Selection"
+msgstr "保存選擇"
+
+#: ../templates/classic/html/user.forgotten.html:3
+msgid "Password reset request - ${tracker}"
+msgstr "口令重設請求 - ${tracker}"
+
+#: ../templates/classic/html/user.forgotten.html:5
+msgid "Password reset request"
+msgstr "口令重設請求"
+
+#: ../templates/classic/html/user.forgotten.html:9
+msgid "You have two options if you have forgotten your password. If you know the email address you registered with, enter it below."
+msgstr "如果你忘了口令將有兩種選擇。如果你知道註冊時的郵件地址,在下面輸入它。"
+
+#: ../templates/classic/html/user.forgotten.html:16
+msgid "Email Address:"
+msgstr "郵件地址:"
+
+#: ../templates/classic/html/user.forgotten.html:24
+#: ../templates/classic/html/user.forgotten.html:34
+msgid "Request password reset"
+msgstr "請求口令重設"
+
+#: ../templates/classic/html/user.forgotten.html:30
+msgid "Or, if you know your username, then enter it below."
+msgstr "或者,如果你知道你的用戶名,則在下面輸入它。"
+
+#: ../templates/classic/html/user.forgotten.html:33
+msgid "Username:"
+msgstr "用戶名:"
+
+#: ../templates/classic/html/user.forgotten.html:39
+msgid "A confirmation email will be sent to you - please follow the instructions within it to complete the reset process."
+msgstr "將發給你一封確認信 - 請按照其中的指令來完成重置處理。"
+
+#: ../templates/classic/html/user.index.html:3
+#: ../templates/minimal/html/user.index.html:3
+msgid "User listing - ${tracker}"
+msgstr "用戶列表 - ${tracker}"
+
+#: ../templates/classic/html/user.index.html:5
+#: ../templates/minimal/html/user.index.html:5
+msgid "User listing"
+msgstr "用戶列表"
+
+#: ../templates/classic/html/user.index.html:14
+#: ../templates/minimal/html/user.index.html:14
+msgid "Username"
+msgstr "用戶名"
+
+#: ../templates/classic/html/user.index.html:15
+msgid "Real name"
+msgstr "真實姓名"
+
+#: ../templates/classic/html/user.index.html:16
+#: ../templates/classic/html/user.item.html:65
+#: ../templates/classic/html/user.register.html:45
+msgid "Organisation"
+msgstr "組織"
+
+#: ../templates/classic/html/user.index.html:17
+#: ../templates/minimal/html/user.index.html:15
+msgid "Email address"
+msgstr "郵件地址"
+
+#: ../templates/classic/html/user.index.html:18
+msgid "Phone number"
+msgstr "電話號碼"
+
+#: ../templates/classic/html/user.index.html:19
+msgid "Retire"
+msgstr "收回"
+
+#: ../templates/classic/html/user.index.html:32
+msgid "retire"
+msgstr "收回"
+
+#: ../templates/classic/html/user.item.html:7
+msgid "User ${id}: ${title} - ${tracker}"
+msgstr "用戶 [${id}]: ${title} - ${tracker}"
+
+#: ../templates/classic/html/user.item.html:10
+msgid "New User - ${tracker}"
+msgstr "新用戶 - ${tracker}"
+
+#: ../templates/classic/html/user.item.html:14
+#: ../templates/minimal/html/user.item.html:6
+msgid "New User"
+msgstr "新用戶"
+
+#: ../templates/classic/html/user.item.html:16
+#: ../templates/minimal/html/user.item.html:8
+msgid "New User Editing"
+msgstr "新用戶編輯"
+
+#: ../templates/classic/html/user.item.html:19
+#: ../templates/minimal/html/user.item.html:11
+msgid "User${id}"
+msgstr "用戶 [${id}]"
+
+#: ../templates/classic/html/user.item.html:22
+#: ../templates/minimal/html/user.item.html:14
+msgid "User${id} Editing"
+msgstr "用戶 [${id}] 編輯"
+
+#: ../templates/classic/html/user.item.html:38
+#: ../templates/classic/html/user.register.html:21
+#: ../templates/minimal/html/user.item.html:27
+#: ../templates/minimal/html/user.item.html:67
+#: ../templates/minimal/html/user.register.html:26
+msgid "Login Name"
+msgstr "登錄名"
+
+#: ../templates/classic/html/user.item.html:42
+#: ../templates/classic/html/user.register.html:25
+#: ../templates/minimal/html/user.item.html:31
+#: ../templates/minimal/html/user.register.html:30
+msgid "Login Password"
+msgstr "登錄口令"
+
+#: ../templates/classic/html/user.item.html:46
+#: ../templates/classic/html/user.register.html:29
+#: ../templates/minimal/html/user.item.html:35
+#: ../templates/minimal/html/user.register.html:34
+msgid "Confirm Password"
+msgstr "口令確認"
+
+#: ../templates/classic/html/user.item.html:50
+#: ../templates/classic/html/user.register.html:33
+#: ../templates/minimal/html/user.item.html:39
+#: ../templates/minimal/html/user.register.html:38
+msgid "Roles"
+msgstr "角色"
+
+#: ../templates/classic/html/user.item.html:56
+msgid "(to give the user more than one role, enter a comma,separated,list)"
+msgstr "(為給用戶指定多個角色,用逗號分隔它們)"
+
+#: ../templates/classic/html/user.item.html:61
+#: ../templates/classic/html/user.register.html:41
+msgid "Phone"
+msgstr "電話"
+
+#: ../templates/classic/html/user.item.html:69
+msgid "Timezone"
+msgstr "時區"
+
+#: ../templates/classic/html/user.item.html:73
+msgid "(this is a numeric hour offset, the default is ${zone})"
+msgstr "(這是數字的小時偏移量,預設值是 ${zone})"
+
+#: ../templates/classic/html/user.item.html:78
+#: ../templates/classic/html/user.register.html:49
+#: ../templates/minimal/html/user.item.html:47
+#: ../templates/minimal/html/user.item.html:71
+#: ../templates/minimal/html/user.register.html:46
+msgid "E-mail address"
+msgstr "郵件地址"
+
+#: ../templates/classic/html/user.item.html:82
+#: ../templates/classic/html/user.register.html:53
+#: ../templates/minimal/html/user.item.html:51
+#: ../templates/minimal/html/user.register.html:50
+msgid "Alternate E-mail addresses<br>One address per line"
+msgstr "修改郵件地址<br>每行一個地址"
+
+#: ../templates/classic/html/user.register.html:4
+#: ../templates/classic/html/user.register.html:7
+#: ../templates/minimal/html/user.register.html:4
+#: ../templates/minimal/html/user.register.html:7
+msgid "Registering with ${tracker}"
+msgstr "用 ${tracker} 註冊"
+
+#: ../templates/classic/html/user.rego_progress.html:4
+#: ../templates/minimal/html/user.rego_progress.html:4
+msgid "Registration in progress - ${tracker}"
+msgstr "註冊正在處理 - ${tracker}"
+
+#: ../templates/classic/html/user.rego_progress.html:6
+#: ../templates/minimal/html/user.rego_progress.html:6
+msgid "Registration in progress..."
+msgstr "正在註冊中..."
+
+#: ../templates/classic/html/user.rego_progress.html:10
+#: ../templates/minimal/html/user.rego_progress.html:10
+msgid "You will shortly receive an email to confirm your registration. To complete the registration process, visit the link indicated in the email."
+msgstr "你將很快收到一封確認信。為了完成註冊過程,請訪問郵件中指示的鏈接。"
+
+#: ../templates/minimal/html/home.html:2
+msgid "Tracker home - ${tracker}"
+msgstr "Tracker根目錄 - ${tracker}"
+
+#: ../templates/minimal/html/home.html:4
+msgid "Tracker home"
+msgstr "Tracker根目錄"
+
+#: ../templates/minimal/html/home.html:16
+msgid "Please select from one of the menu options on the left."
+msgstr "請在左側的菜單選項中選擇一項"
+
+#: ../templates/minimal/html/home.html:19
+msgid "Please log in or register."
+msgstr "請登錄或註冊。"
+
+#: ../templates/minimal/html/page.html:38
+msgid "Hello,<br>${user}"
+msgstr "你好,<br>${user}"
+
+#: ../templates/minimal/html/user.item.html:3
+msgid "User editing - ${tracker}"
+msgstr "用戶編輯 - ${tracker}"
+

Added: tracker/vendor/roundup/current/patches/20020205.alternate_auth
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/patches/20020205.alternate_auth	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,254 @@
+From daniel_clark at us.ibm.com Wed Feb  6 04:27:15 2002
+X-Sieve: cmu-sieve 2.0
+Return-Path: <roundup-devel-admin at lists.sourceforge.net>
+Received: (from uucp at localhost)
+	by crown.off.ekorp.com (8.9.3/8.9.3) id RAA12435
+	for rjones at ekit-inc.com; Tue, 5 Feb 2002 17:30:24 GMT
+Received: from usw-sf-fw2.sourceforge.net(216.136.171.252), claiming to be "usw-sf-list1.sourceforge.net"
+ via SMTP by mx3.ekorp.com, id smtpdAAALJaWqy; Tue Feb  5 17:30:22 2002
+Received: from localhost ([127.0.0.1] helo=usw-sf-list1.sourceforge.net)
+	by usw-sf-list1.sourceforge.net with esmtp (Exim 3.31-VA-mm2 #1 (Debian))
+	id 16Y9Q6-0002kj-00; Tue, 05 Feb 2002 09:30:14 -0800
+Received: from lotus2.lotus.com ([129.42.241.42])
+	by usw-sf-list1.sourceforge.net with esmtp (Exim 3.31-VA-mm2 #1 (Debian))
+	id 16Y9Ps-0002ee-00
+	for <roundup-devel at lists.sourceforge.net>; Tue, 05 Feb 2002 09:30:00 -0800
+Received: from internet2.lotus.com (internet2 [172.16.131.236])
+	by lotus2.lotus.com (8.12.1/8.12.1) with ESMTP id g15HUnTQ013140
+	for <roundup-devel at lists.sourceforge.net>; Tue, 5 Feb 2002 12:30:54 -0500 (EST)
+Received: from a3mail.lotus.com (a3mail.lotus.com [9.95.5.66])
+	by internet2.lotus.com (8.12.1/8.12.1) with ESMTP id g15HTHS0005917
+	for <roundup-devel at lists.sourceforge.net>; Tue, 5 Feb 2002 12:29:17 -0500 (EST)
+To: roundup-devel at lists.sourceforge.net
+X-Mailer: Lotus Notes Release 5.0.8  June 18, 2001
+Message-ID: <OF2C7B87C4.DF1574A8-ON85256B56.0060B9A2 at lotus.com>
+From: "Daniel Clark/CAM/Lotus" <daniel_clark at us.ibm.com>
+X-MIMETrack: Serialize by Router on A3MAIL/CAM/H/Lotus(Build V5010_01222002 |January 22, 2002) at
+ 02/05/2002 12:25:48 PM
+MIME-Version: 1.0
+Content-type: text/plain;
+  charset=iso-8859-1
+Content-transfer-encoding: quoted-printable
+Subject: [Roundup-devel] Alternative authentication for roundup
+Sender: roundup-devel-admin at lists.sourceforge.net
+Errors-To: roundup-devel-admin at lists.sourceforge.net
+X-BeenThere: roundup-devel at lists.sourceforge.net
+X-Mailman-Version: 2.0.5
+Precedence: bulk
+List-Help: <mailto:roundup-devel-request at lists.sourceforge.net?subject=help>
+List-Post: <mailto:roundup-devel at lists.sourceforge.net>
+List-Subscribe: <https://lists.sourceforge.net/lists/listinfo/roundup-devel>,
+	<mailto:roundup-devel-request at lists.sourceforge.net?subject=subscribe>
+List-Id: <roundup-devel.lists.sourceforge.net>
+List-Unsubscribe: <https://lists.sourceforge.net/lists/listinfo/roundup-devel>,
+	<mailto:roundup-devel-request at lists.sourceforge.net?subject=unsubscribe>
+List-Archive: <http://www.geocrawler.com/redir-sf.php3?list=roundup-devel>
+X-Original-Date: Tue, 5 Feb 2002 12:27:15 -0500
+Date: Tue, 5 Feb 2002 12:27:15 -0500
+Status: R 
+X-Status: N
+
+I'm trying to get roundup to work with an alternative method of
+authentication (due to a corporate requirement of using a common intran=
+et
+password). I've created an "altauth" module to abstract the details of =
+the
+authentication. Since the hyperdb usernames and passwords seem to be
+referenced in a lot of places in the code, I am just creating hyperdb
+entries for the users if they exist and enter their correct passwords
+against the alternate authentication source. For the most part this eff=
+ects
+the login_action function in cgi_client.py. I've completed some changes=
+
+that make this work for the web interface, but as I am new to roundup a=
+nd
+relatively new to python I thought I'd post the changes for review. If
+others would find this functionality useful I would be happy if these
+changes (probably reworked) could make it into future releases.
+
+The main things I think I still need to do are add equivalent changes t=
+o
+mailgw.py and handle messages from the alternative authentication sourc=
+e
+better.
+
+--- cgi_client.py Tue Feb  5 21:56:30 2002
++++ cgi_client.py-altauth     Tue Feb  5 21:56:30 2002
+@@ -27,6 +27,13 @@
+ import roundupdb, htmltemplate, date, hyperdb, password
+ from roundup.i18n import _
+
++try:
++    from altauth import altauth
++    import password as password_module
++    altauth_exists =3D 1
++except:
++    altauth_exists =3D 0
++
+ class Unauthorised(ValueError):
+     pass
+
+@@ -807,7 +814,24 @@
+             password =3D self.form['__login_password'].value
+         else:
+             password =3D ''
++        # if using alternate authentication, perform it.
++        if altauth_exists:
++            auth =3D altauth(self.user, password)
+         # make sure the user exists
++        if altauth_exists:
++            if auth.exists:
++                try:
++                    uid =3D self.db.user.lookup(self.user)
++                except KeyError:
++                    username =3D str(self.user)
++                    self.db =3D self.instance.open('admin')
++                    cl =3D self.db.user
++                    props =3D {'username':username, 'realname':auth.re=
+alname,
++                             'organisation':auth.org, 'address':auth.e=
+mail,
++                             'phone':auth.phone}
++                    uid =3D cl.create(**props)
++                    self.user =3D cl.get(uid, 'username')
++                    self.db.commit()
+         try:
+             uid =3D self.db.user.lookup(self.user)
+         except KeyError:
+@@ -819,6 +843,20 @@
+             return 0
+
+         # and that the password is correct
++        if altauth_exists:
++            if auth.success:
++                name =3D str(self.user)
++                self.db =3D self.instance.open(name)
++                value =3D password_module.Password(password.strip())
++                password_dict =3D {'password':value}
++                user =3D self.db.user
++                user.set(uid, **password_dict)
++                self.db.commit()
++            else:
++                self.make_user_anonymous()o
++                action =3D self.form['__destination_url'].value
++                self.login(message=3D_(auth.message), action=3Daction)=
+
++                return 0
+         pw =3D self.db.user.get(uid, 'password')
+         if password !=3D pw:
+             self.make_user_anonymous()
+
+
+example altauth.py:
+
+__doc__ =3D """
+Alternative authentication for roundup
+"""
+
+import pipes, os, string
+
+class altauth:
+    """
+    Arguments:
+        username : username
+        password : password in plaintext
+
+    Instance variables:
+        realname : username's real name
+        org      : username's organization
+        email    : username's email address
+        phone    : username's phone number
+
+        code     : return code from alternate authentication
+        message  : message from alternate authentication
+        exists   : does user exist in alternate autentication source?
+        success  : did user enter a valid user / password combo?
+    """
+    def __init__(self, username=3DNone, password=3DNone):
+        # Make sure user and password have values - else java cwauthcmd=
+ hangs.
+        if username is None:
+            username =3D "test"
+        if password is None:
+            password =3D "test"
+
+        # In Bluepages, your username is your email address, but this m=
+ight not
+        # be true for other authentication sources.
+        self.email =3D username
+
+        # Get realname, phone and org from Bluepages
+        cmd =3D "phone ldap emailaddress=3D%s format givenname sn telep=
+honenumber dept" % self.email
+        s =3D os.popen(cmd).readlines()[0].strip().split()
+        self.realname =3D string.join(s[:-2])
+        self.phone =3D s[-2]
+        self.org =3D s[-1]
+
+        # Open a pipeline to java cwauth stuff. The most secure option =
+I could think of
+        # besides JPE (Java Python Extension), which I couldn't get to =
+work.
+        os.umask(077)
+        t=3Dpipes.Template()
+        t.append('java cwauthcmd', '--')
+        tmpfile =3D os.tmpnam()
+        f=3Dt.open(tmpfile, 'w')
+        f.write(username + " " + password)
+        f.close()
+        self.code =3D int(open(tmpfile).read().strip())
+        os.remove(tmpfile)
+
+        if self.code =3D=3D 0:
+            self.message =3D "Success. The authentication was successfu=
+l."
+            self.exists =3D 1
+            self.success =3D 1
+        elif self.code =3D=3D 2:
+            self.message =3D "Not registered. Visit http://w3.ibm.com/p=
+assword/"
+            self.exists =3D 0
+            self.success =3D 0
+        elif self.code =3D=3D 3:
+            self.message =3D "LDAP Error. There was an error communicat=
+ing with Bluepages."
+            self.exists =3D 0
+            self.success =3D 0
+        elif self.code =3D=3D 4:
+            self.message  =3D "No Record Found. No user was found havin=
+g that e-mail address."
+            self.exists =3D 0
+            self.success =3D 0
+        elif self.code =3D=3D 5:
+            self.message =3D "Multiple Records Found. More than one ent=
+ry exists for that e-mail address."
+            self.exists =3D 1
+            self.success =3D 0
+        elif self.code =3D=3D 6:
+            self.message =3D "Incorrect password. Try again or visit ht=
+tp://w3.ibm.com/password"
+            self.exists =3D 1
+            self.success =3D 0
+        else:
+            self.message =3D "Unknown result code. Contact daniel_clark=
+ at us.ibm.com"
+            self.exists =3D 0
+            self.success =3D 0
+
+
+--
+Daniel Clark =A7 Sys Admin & Assistant Release Engineer
+IBM =BB Lotus =BB Messaging Technology Group =A7 http://w3.mtg.lotus.co=
+m
+Tieline 693-7353 =A7 External 617-693-7353 =A7 Mobile 617-877-0702
+AIM as djbclark =A7 Sametime as Daniel Clark/CAM/Lotus
+=
+
+
+
+_______________________________________________
+Roundup-devel mailing list
+Roundup-devel at lists.sourceforge.net
+https://lists.sourceforge.net/lists/listinfo/roundup-devel
+
+

Added: tracker/vendor/roundup/current/roundup/.cvsignore
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/.cvsignore	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,3 @@
+*.pyc
+*.pyo
+*.cover

Added: tracker/vendor/roundup/current/roundup/__init__.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/__init__.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,73 @@
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+# 
+# $Id: __init__.py,v 1.38 2006/04/27 05:55:18 richard Exp $
+
+'''Roundup - issue tracking for knowledge workers.
+
+This is a simple-to-use and -install issue-tracking system with
+command-line, web and e-mail interfaces.
+
+Roundup manages a number of issues (with properties such as
+"description", "priority", and so on) and provides the ability to (a) submit
+new issues, (b) find and edit existing issues, and (c) discuss issues with
+other participants. The system will facilitate communication among the
+participants by managing discussions and notifying interested parties when
+issues are edited. 
+
+Roundup's structure is that of a cake::
+
+  _________________________________________________________________________
+ |  E-mail Client   |   Web Browser   |   Detector Scripts   |    Shell    |
+ |------------------+-----------------+----------------------+-------------|
+ |   E-mail User    |    Web User     |      Detector        |   Command   | 
+ |-------------------------------------------------------------------------|
+ |                         Roundup Database Layer                          |
+ |-------------------------------------------------------------------------|
+ |                          Hyperdatabase Layer                            |
+ |-------------------------------------------------------------------------|
+ |                             Storage Layer                               |
+  -------------------------------------------------------------------------
+
+1. The first layer represents the users (chocolate).
+2. The second layer is the Roundup interface to the users (vanilla).
+3. The third and fourth layers are the internal Roundup database storage
+   mechanisms (strawberry).
+4. The final, lowest layer is the underlying database storage (rum).
+
+These are implemented in the code in the following manner::
+
+  E-mail User: roundup-mailgw and roundup.mailgw
+     Web User: cgi-bin/roundup.cgi or roundup-server over
+               roundup.cgi.client and roundup.cgi.template
+     Detector: roundup.roundupdb and templates/<template>/detectors
+      Command: roundup-admin
+   Roundup DB: roundup.roundupdb
+     Hyper DB: roundup.hyperdb, roundup.date
+      Storage: roundup.backends.*
+
+Additionally, there is a directory of unit tests in "test".
+
+For more information, see the original overview and specification documents
+written by Ka-Ping Yee in the "doc" directory. If nothing else, it has a
+much prettier cake :)
+'''
+__docformat__ = 'restructuredtext'
+
+__version__ = '1.1.2'
+
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/roundup/admin.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/admin.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,1466 @@
+#! /usr/bin/env python
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+#
+# $Id: admin.py,v 1.99 2006/04/27 03:38:53 richard Exp $
+
+'''Administration commands for maintaining Roundup trackers.
+'''
+__docformat__ = 'restructuredtext'
+
+import csv, getopt, getpass, os, re, shutil, sys, UserDict
+
+from roundup import date, hyperdb, roundupdb, init, password, token
+from roundup import __version__ as roundup_version
+import roundup.instance
+from roundup.configuration import CoreConfig
+from roundup.i18n import _
+
+class CommandDict(UserDict.UserDict):
+    '''Simple dictionary that lets us do lookups using partial keys.
+
+    Original code submitted by Engelbert Gruber.
+    '''
+    _marker = []
+    def get(self, key, default=_marker):
+        if self.data.has_key(key):
+            return [(key, self.data[key])]
+        keylist = self.data.keys()
+        keylist.sort()
+        l = []
+        for ki in keylist:
+            if ki.startswith(key):
+                l.append((ki, self.data[ki]))
+        if not l and default is self._marker:
+            raise KeyError, key
+        return l
+
+class UsageError(ValueError):
+    pass
+
+class AdminTool:
+    ''' A collection of methods used in maintaining Roundup trackers.
+
+        Typically these methods are accessed through the roundup-admin
+        script. The main() method provided on this class gives the main
+        loop for the roundup-admin script.
+
+        Actions are defined by do_*() methods, with help for the action
+        given in the method docstring.
+
+        Additional help may be supplied by help_*() methods.
+    '''
+    def __init__(self):
+        self.commands = CommandDict()
+        for k in AdminTool.__dict__.keys():
+            if k[:3] == 'do_':
+                self.commands[k[3:]] = getattr(self, k)
+        self.help = {}
+        for k in AdminTool.__dict__.keys():
+            if k[:5] == 'help_':
+                self.help[k[5:]] = getattr(self, k)
+        self.tracker_home = ''
+        self.db = None
+
+    def get_class(self, classname):
+        '''Get the class - raise an exception if it doesn't exist.
+        '''
+        try:
+            return self.db.getclass(classname)
+        except KeyError:
+            raise UsageError, _('no such class "%(classname)s"')%locals()
+
+    def props_from_args(self, args):
+        ''' Produce a dictionary of prop: value from the args list.
+
+            The args list is specified as ``prop=value prop=value ...``.
+        '''
+        props = {}
+        for arg in args:
+            if arg.find('=') == -1:
+                raise UsageError, _('argument "%(arg)s" not propname=value'
+                    )%locals()
+            l = arg.split('=')
+            if len(l) < 2:
+                raise UsageError, _('argument "%(arg)s" not propname=value'
+                    )%locals()
+            key, value = l[0], '='.join(l[1:])
+            if value:
+                props[key] = value
+            else:
+                props[key] = None
+        return props
+
+    def usage(self, message=''):
+        ''' Display a simple usage message.
+        '''
+        if message:
+            message = _('Problem: %(message)s\n\n')%locals()
+        print _('''%(message)sUsage: roundup-admin [options] [<command> <arguments>]
+
+Options:
+ -i instance home  -- specify the issue tracker "home directory" to administer
+ -u                -- the user[:password] to use for commands
+ -d                -- print full designators not just class id numbers
+ -c                -- when outputting lists of data, comma-separate them.
+                      Same as '-S ","'.
+ -S <string>       -- when outputting lists of data, string-separate them
+ -s                -- when outputting lists of data, space-separate them.
+                      Same as '-S " "'.
+ -V                -- be verbose when importing
+ -v                -- report Roundup and Python versions (and quit)
+
+ Only one of -s, -c or -S can be specified.
+
+Help:
+ roundup-admin -h
+ roundup-admin help                       -- this help
+ roundup-admin help <command>             -- command-specific help
+ roundup-admin help all                   -- all available help
+''')%locals()
+        self.help_commands()
+
+    def help_commands(self):
+        ''' List the commands available with their help summary.
+        '''
+        print _('Commands:'),
+        commands = ['']
+        for command in self.commands.values():
+            h = _(command.__doc__).split('\n')[0]
+            commands.append(' '+h[7:])
+        commands.sort()
+        commands.append(_(
+"""Commands may be abbreviated as long as the abbreviation
+matches only one command, e.g. l == li == lis == list."""))
+        print '\n'.join(commands)
+        print
+
+    def help_commands_html(self, indent_re=re.compile(r'^(\s+)\S+')):
+        ''' Produce an HTML command list.
+        '''
+        commands = self.commands.values()
+        def sortfun(a, b):
+            return cmp(a.__name__, b.__name__)
+        commands.sort(sortfun)
+        for command in commands:
+            h = _(command.__doc__).split('\n')
+            name = command.__name__[3:]
+            usage = h[0]
+            print '''
+<tr><td valign=top><strong>%(name)s</strong></td>
+    <td><tt>%(usage)s</tt><p>
+<pre>''' % locals()
+            indent = indent_re.match(h[3])
+            if indent: indent = len(indent.group(1))
+            for line in h[3:]:
+                if indent:
+                    print line[indent:]
+                else:
+                    print line
+            print '</pre></td></tr>\n'
+
+    def help_all(self):
+        print _('''
+All commands (except help) require a tracker specifier. This is just
+the path to the roundup tracker you're working with. A roundup tracker
+is where roundup keeps the database and configuration file that defines
+an issue tracker. It may be thought of as the issue tracker's "home
+directory". It may be specified in the environment variable TRACKER_HOME
+or on the command line as "-i tracker".
+
+A designator is a classname and a nodeid concatenated, eg. bug1, user10, ...
+
+Property values are represented as strings in command arguments and in the
+printed results:
+ . Strings are, well, strings.
+ . Date values are printed in the full date format in the local time zone,
+   and accepted in the full format or any of the partial formats explained
+   below.
+ . Link values are printed as node designators. When given as an argument,
+   node designators and key strings are both accepted.
+ . Multilink values are printed as lists of node designators joined
+   by commas.  When given as an argument, node designators and key
+   strings are both accepted; an empty string, a single node, or a list
+   of nodes joined by commas is accepted.
+
+When property values must contain spaces, just surround the value with
+quotes, either ' or ". A single space may also be backslash-quoted. If a
+value must contain a quote character, it must be backslash-quoted or inside
+quotes. Examples:
+           hello world      (2 tokens: hello, world)
+           "hello world"    (1 token: hello world)
+           "Roch'e" Compaan (2 tokens: Roch'e Compaan)
+           Roch\\'e Compaan  (2 tokens: Roch'e Compaan)
+           address="1 2 3"  (1 token: address=1 2 3)
+           \\\\               (1 token: \\)
+           \\n\\r\\t           (1 token: a newline, carriage-return and tab)
+
+When multiple nodes are specified to the roundup get or roundup set
+commands, the specified properties are retrieved or set on all the listed
+nodes.
+
+When multiple results are returned by the roundup get or roundup find
+commands, they are printed one per line (default) or joined by commas (with
+the -c) option.
+
+Where the command changes data, a login name/password is required. The
+login may be specified as either "name" or "name:password".
+ . ROUNDUP_LOGIN environment variable
+ . the -u command-line option
+If either the name or password is not supplied, they are obtained from the
+command-line.
+
+Date format examples:
+  "2000-04-17.03:45" means <Date 2000-04-17.08:45:00>
+  "2000-04-17" means <Date 2000-04-17.00:00:00>
+  "01-25" means <Date yyyy-01-25.00:00:00>
+  "08-13.22:13" means <Date yyyy-08-14.03:13:00>
+  "11-07.09:32:43" means <Date yyyy-11-07.14:32:43>
+  "14:25" means <Date yyyy-mm-dd.19:25:00>
+  "8:47:11" means <Date yyyy-mm-dd.13:47:11>
+  "." means "right now"
+
+Command help:
+''')
+        for name, command in self.commands.items():
+            print _('%s:')%name
+            print '   ', _(command.__doc__)
+
+    def do_help(self, args, nl_re=re.compile('[\r\n]'),
+            indent_re=re.compile(r'^(\s+)\S+')):
+        ""'''Usage: help topic
+        Give help about topic.
+
+        commands  -- list commands
+        <command> -- help specific to a command
+        initopts  -- init command options
+        all       -- all available help
+        '''
+        if len(args)>0:
+            topic = args[0]
+        else:
+            topic = 'help'
+
+
+        # try help_ methods
+        if self.help.has_key(topic):
+            self.help[topic]()
+            return 0
+
+        # try command docstrings
+        try:
+            l = self.commands.get(topic)
+        except KeyError:
+            print _('Sorry, no help for "%(topic)s"')%locals()
+            return 1
+
+        # display the help for each match, removing the docsring indent
+        for name, help in l:
+            lines = nl_re.split(_(help.__doc__))
+            print lines[0]
+            indent = indent_re.match(lines[1])
+            if indent: indent = len(indent.group(1))
+            for line in lines[1:]:
+                if indent:
+                    print line[indent:]
+                else:
+                    print line
+        return 0
+
+    def listTemplates(self):
+        ''' List all the available templates.
+
+        Look in the following places, where the later rules take precedence:
+
+         1. <prefix>/share/roundup/templates/*
+            this should be the standard place to find them when Roundup is
+            installed
+         2. <roundup.admin.__file__>/../templates/*
+            this will be used if Roundup's run in the distro (aka. source)
+            directory
+         3. <current working dir>/*
+            this is for when someone unpacks a 3rd-party template
+         4. <current working dir>
+            this is for someone who "cd"s to the 3rd-party template dir
+        '''
+        # OK, try <prefix>/share/roundup/templates
+        # -- this module (roundup.admin) will be installed in something
+        # like:
+        #    /usr/lib/python2.2/site-packages/roundup/admin.py  (5 dirs up)
+        #    c:\python22\lib\site-packages\roundup\admin.py     (4 dirs up)
+        # we're interested in where the "lib" directory is - ie. the /usr/
+        # part
+        templates = {}
+        for N in 4, 5:
+            path = __file__
+            # move up N elements in the path
+            for i in range(N):
+                path = os.path.dirname(path)
+            tdir = os.path.join(path, 'share', 'roundup', 'templates')
+            if os.path.isdir(tdir):
+                templates = init.listTemplates(tdir)
+                break
+
+        # OK, now try as if we're in the roundup source distribution
+        # directory, so this module will be in .../roundup-*/roundup/admin.py
+        # and we're interested in the .../roundup-*/ part.
+        path = __file__
+        for i in range(2):
+            path = os.path.dirname(path)
+        tdir = os.path.join(path, 'templates')
+        if os.path.isdir(tdir):
+            templates.update(init.listTemplates(tdir))
+
+        # Try subdirs of the current dir
+        templates.update(init.listTemplates(os.getcwd()))
+
+        # Finally, try the current directory as a template
+        template = init.loadTemplateInfo(os.getcwd())
+        if template:
+            templates[template['name']] = template
+
+        return templates
+
+    def help_initopts(self):
+        templates = self.listTemplates()
+        print _('Templates:'), ', '.join(templates.keys())
+        import roundup.backends
+        backends = roundup.backends.list_backends()
+        print _('Back ends:'), ', '.join(backends)
+
+    def do_install(self, tracker_home, args):
+        ""'''Usage: install [template [backend [admin password [key=val[,key=val]]]]]
+        Install a new Roundup tracker.
+
+        The command will prompt for the tracker home directory
+        (if not supplied through TRACKER_HOME or the -i option).
+        The template, backend and admin password may be specified
+        on the command-line as arguments, in that order.
+
+        The last command line argument allows to pass initial values
+        for config options.  For example, passing
+        "web_http_auth=no,rdbms_user=dinsdale" will override defaults
+        for options http_auth in section [web] and user in section [rdbms].
+        Please be careful to not use spaces in this argument! (Enclose
+        whole argument in quotes if you need spaces in option value).
+
+        The initialise command must be called after this command in order
+        to initialise the tracker's database. You may edit the tracker's
+        initial database contents before running that command by editing
+        the tracker's dbinit.py module init() function.
+
+        See also initopts help.
+        '''
+        if len(args) < 1:
+            raise UsageError, _('Not enough arguments supplied')
+
+        # make sure the tracker home can be created
+        tracker_home = os.path.abspath(tracker_home)
+        parent = os.path.split(tracker_home)[0]
+        if not os.path.exists(parent):
+            raise UsageError, _('Instance home parent directory "%(parent)s"'
+                ' does not exist')%locals()
+
+        config_ini_file = os.path.join(tracker_home, CoreConfig.INI_FILE)
+        # check for both old- and new-style configs
+        if filter(os.path.exists, [config_ini_file,
+                os.path.join(tracker_home, 'config.py')]):
+            ok = raw_input(_(
+"""WARNING: There appears to be a tracker in "%(tracker_home)s"!
+If you re-install it, you will lose all the data!
+Erase it? Y/N: """) % locals())
+            if ok.strip().lower() != 'y':
+                return 0
+
+            # clear it out so the install isn't confused
+            shutil.rmtree(tracker_home)
+
+        # select template
+        templates = self.listTemplates()
+        template = len(args) > 1 and args[1] or ''
+        if not templates.has_key(template):
+            print _('Templates:'), ', '.join(templates.keys())
+        while not templates.has_key(template):
+            template = raw_input(_('Select template [classic]: ')).strip()
+            if not template:
+                template = 'classic'
+
+        # select hyperdb backend
+        import roundup.backends
+        backends = roundup.backends.list_backends()
+        backend = len(args) > 2 and args[2] or ''
+        if backend not in backends:
+            print _('Back ends:'), ', '.join(backends)
+        while backend not in backends:
+            backend = raw_input(_('Select backend [anydbm]: ')).strip()
+            if not backend:
+                backend = 'anydbm'
+        # XXX perform a unit test based on the user's selections
+
+        # Process configuration file definitions
+        if len(args) > 3:
+            try:
+                defns = dict([item.split("=") for item in args[3].split(",")])
+            except:
+                print _('Error in configuration settings: "%s"') % args[3]
+                raise
+        else:
+            defns = {}
+
+        # install!
+        init.install(tracker_home, templates[template]['path'], settings=defns)
+        init.write_select_db(tracker_home, backend)
+
+        print _("""
+---------------------------------------------------------------------------
+ You should now edit the tracker configuration file:
+   %(config_file)s""") % {"config_file": config_ini_file}
+
+        # find list of options that need manual adjustments
+        # XXX config._get_unset_options() is marked as private
+        #   (leading underscore).  make it public or don't care?
+        need_set = CoreConfig(tracker_home)._get_unset_options()
+        if need_set:
+            print _(" ... at a minimum, you must set following options:")
+            for section, options in need_set.items():
+                print "   [%s]: %s" % (section, ", ".join(options))
+
+        # note about schema modifications
+        print _("""
+ If you wish to modify the database schema,
+ you should also edit the schema file:
+   %(database_config_file)s
+ You may also change the database initialisation file:
+   %(database_init_file)s
+ ... see the documentation on customizing for more information.
+
+ You MUST run the "roundup-admin initialise" command once you've performed
+ the above steps.
+---------------------------------------------------------------------------
+""") % {
+    'database_config_file': os.path.join(tracker_home, 'schema.py'),
+    'database_init_file': os.path.join(tracker_home, 'initial_data.py'),
+}
+        return 0
+
+    def do_genconfig(self, args):
+        ""'''Usage: genconfig <filename>
+        Generate a new tracker config file (ini style) with default values
+        in <filename>.
+        '''
+        if len(args) < 1:
+            raise UsageError, _('Not enough arguments supplied')
+        config = CoreConfig()
+        config.save(args[0])
+
+    def do_initialise(self, tracker_home, args):
+        ""'''Usage: initialise [adminpw]
+        Initialise a new Roundup tracker.
+
+        The administrator details will be set at this step.
+
+        Execute the tracker's initialisation function dbinit.init()
+        '''
+        # password
+        if len(args) > 1:
+            adminpw = args[1]
+        else:
+            adminpw = ''
+            confirm = 'x'
+            while adminpw != confirm:
+                adminpw = getpass.getpass(_('Admin Password: '))
+                confirm = getpass.getpass(_('       Confirm: '))
+
+        # make sure the tracker home is installed
+        if not os.path.exists(tracker_home):
+            raise UsageError, _('Instance home does not exist')%locals()
+        try:
+            tracker = roundup.instance.open(tracker_home)
+        except roundup.instance.TrackerError:
+            raise UsageError, _('Instance has not been installed')%locals()
+
+        # is there already a database?
+        if tracker.exists():
+            ok = raw_input(_(
+"""WARNING: The database is already initialised!
+If you re-initialise it, you will lose all the data!
+Erase it? Y/N: """))
+            if ok.strip().lower() != 'y':
+                return 0
+
+            backend = tracker.get_backend_name()
+
+            # nuke it
+            tracker.nuke()
+
+            # re-write the backend select file
+            init.write_select_db(tracker_home, backend)
+
+        # GO
+        tracker.init(password.Password(adminpw))
+
+        return 0
+
+
+    def do_get(self, args):
+        ""'''Usage: get property designator[,designator]*
+        Get the given property of one or more designator(s).
+
+        Retrieves the property value of the nodes specified
+        by the designators.
+        '''
+        if len(args) < 2:
+            raise UsageError, _('Not enough arguments supplied')
+        propname = args[0]
+        designators = args[1].split(',')
+        l = []
+        for designator in designators:
+            # decode the node designator
+            try:
+                classname, nodeid = hyperdb.splitDesignator(designator)
+            except hyperdb.DesignatorError, message:
+                raise UsageError, message
+
+            # get the class
+            cl = self.get_class(classname)
+            try:
+                id=[]
+                if self.separator:
+                    if self.print_designator:
+                        # see if property is a link or multilink for
+                        # which getting a desginator make sense.
+                        # Algorithm: Get the properties of the
+                        #     current designator's class. (cl.getprops)
+                        # get the property object for the property the
+                        #     user requested (properties[propname])
+                        # verify its type (isinstance...)
+                        # raise error if not link/multilink
+                        # get class name for link/multilink property
+                        # do the get on the designators
+                        # append the new designators
+                        # print
+                        properties = cl.getprops()
+                        property = properties[propname]
+                        if not (isinstance(property, hyperdb.Multilink) or
+                          isinstance(property, hyperdb.Link)):
+                            raise UsageError, _('property %s is not of type Multilink or Link so -d flag does not apply.')%propname
+                        propclassname = self.db.getclass(property.classname).classname
+                        id = cl.get(nodeid, propname)
+                        for i in id:
+                            l.append(propclassname + i)
+                    else:
+                        id = cl.get(nodeid, propname)
+                        for i in id:
+                            l.append(i)
+                else:
+                    if self.print_designator:
+                        properties = cl.getprops()
+                        property = properties[propname]
+                        if not (isinstance(property, hyperdb.Multilink) or
+                          isinstance(property, hyperdb.Link)):
+                            raise UsageError, _('property %s is not of type Multilink or Link so -d flag does not apply.')%propname
+                        propclassname = self.db.getclass(property.classname).classname
+                        id = cl.get(nodeid, propname)
+                        for i in id:
+                            print propclassname + i
+                    else:
+                        print cl.get(nodeid, propname)
+            except IndexError:
+                raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
+            except KeyError:
+                raise UsageError, _('no such %(classname)s property '
+                    '"%(propname)s"')%locals()
+        if self.separator:
+            print self.separator.join(l)
+
+        return 0
+
+
+    def do_set(self, args):
+        ""'''Usage: set items property=value property=value ...
+        Set the given properties of one or more items(s).
+
+        The items are specified as a class or as a comma-separated
+        list of item designators (ie "designator[,designator,...]").
+
+        This command sets the properties to the values for all designators
+        given. If the value is missing (ie. "property=") then the property
+        is un-set. If the property is a multilink, you specify the linked
+        ids for the multilink as comma-separated numbers (ie "1,2,3").
+        '''
+        if len(args) < 2:
+            raise UsageError, _('Not enough arguments supplied')
+        from roundup import hyperdb
+
+        designators = args[0].split(',')
+        if len(designators) == 1:
+            designator = designators[0]
+            try:
+                designator = hyperdb.splitDesignator(designator)
+                designators = [designator]
+            except hyperdb.DesignatorError:
+                cl = self.get_class(designator)
+                designators = [(designator, x) for x in cl.list()]
+        else:
+            try:
+                designators = [hyperdb.splitDesignator(x) for x in designators]
+            except hyperdb.DesignatorError, message:
+                raise UsageError, message
+
+        # get the props from the args
+        props = self.props_from_args(args[1:])
+
+        # now do the set for all the nodes
+        for classname, itemid in designators:
+            cl = self.get_class(classname)
+
+            properties = cl.getprops()
+            for key, value in props.items():
+                try:
+                    props[key] = hyperdb.rawToHyperdb(self.db, cl, itemid,
+                        key, value)
+                except hyperdb.HyperdbValueError, message:
+                    raise UsageError, message
+
+            # try the set
+            try:
+                apply(cl.set, (itemid, ), props)
+            except (TypeError, IndexError, ValueError), message:
+                import traceback; traceback.print_exc()
+                raise UsageError, message
+        return 0
+
+    def do_find(self, args):
+        ""'''Usage: find classname propname=value ...
+        Find the nodes of the given class with a given link property value.
+
+        Find the nodes of the given class with a given link property value.
+        The value may be either the nodeid of the linked node, or its key
+        value.
+        '''
+        if len(args) < 1:
+            raise UsageError, _('Not enough arguments supplied')
+        classname = args[0]
+        # get the class
+        cl = self.get_class(classname)
+
+        # handle the propname=value argument
+        props = self.props_from_args(args[1:])
+
+        # convert the user-input value to a value used for find()
+        for propname, value in props.items():
+            if ',' in value:
+                values = value.split(',')
+            else:
+                values = [value]
+            d = props[propname] = {}
+            for value in values:
+                value = hyperdb.rawToHyperdb(self.db, cl, None, propname, value)
+                if isinstance(value, list):
+                    for entry in value:
+                        d[entry] = 1
+                else:
+                    d[value] = 1
+
+        # now do the find
+        try:
+            id = []
+            designator = []
+            if self.separator:
+                if self.print_designator:
+                    id=apply(cl.find, (), props)
+                    for i in id:
+                        designator.append(classname + i)
+                    print self.separator.join(designator)
+                else:
+                    print self.separator.join(apply(cl.find, (), props))
+
+            else:
+                if self.print_designator:
+                    id=apply(cl.find, (), props)
+                    for i in id:
+                        designator.append(classname + i)
+                    print designator
+                else:
+                    print apply(cl.find, (), props)
+        except KeyError:
+            raise UsageError, _('%(classname)s has no property '
+                '"%(propname)s"')%locals()
+        except (ValueError, TypeError), message:
+            raise UsageError, message
+        return 0
+
+    def do_specification(self, args):
+        ""'''Usage: specification classname
+        Show the properties for a classname.
+
+        This lists the properties for a given class.
+        '''
+        if len(args) < 1:
+            raise UsageError, _('Not enough arguments supplied')
+        classname = args[0]
+        # get the class
+        cl = self.get_class(classname)
+
+        # get the key property
+        keyprop = cl.getkey()
+        for key, value in cl.properties.items():
+            if keyprop == key:
+                print _('%(key)s: %(value)s (key property)')%locals()
+            else:
+                print _('%(key)s: %(value)s')%locals()
+
+    def do_display(self, args):
+        ""'''Usage: display designator[,designator]*
+        Show the property values for the given node(s).
+
+        This lists the properties and their associated values for the given
+        node.
+        '''
+        if len(args) < 1:
+            raise UsageError, _('Not enough arguments supplied')
+
+        # decode the node designator
+        for designator in args[0].split(','):
+            try:
+                classname, nodeid = hyperdb.splitDesignator(designator)
+            except hyperdb.DesignatorError, message:
+                raise UsageError, message
+
+            # get the class
+            cl = self.get_class(classname)
+
+            # display the values
+            keys = cl.properties.keys()
+            keys.sort()
+            for key in keys:
+                value = cl.get(nodeid, key)
+                print _('%(key)s: %(value)r')%locals()
+
+    def do_create(self, args):
+        ""'''Usage: create classname property=value ...
+        Create a new entry of a given class.
+
+        This creates a new entry of the given class using the property
+        name=value arguments provided on the command line after the "create"
+        command.
+        '''
+        if len(args) < 1:
+            raise UsageError, _('Not enough arguments supplied')
+        from roundup import hyperdb
+
+        classname = args[0]
+
+        # get the class
+        cl = self.get_class(classname)
+
+        # now do a create
+        props = {}
+        properties = cl.getprops(protected = 0)
+        if len(args) == 1:
+            # ask for the properties
+            for key, value in properties.items():
+                if key == 'id': continue
+                name = value.__class__.__name__
+                if isinstance(value , hyperdb.Password):
+                    again = None
+                    while value != again:
+                        value = getpass.getpass(_('%(propname)s (Password): ')%{
+                            'propname': key.capitalize()})
+                        again = getpass.getpass(_('   %(propname)s (Again): ')%{
+                            'propname': key.capitalize()})
+                        if value != again: print _('Sorry, try again...')
+                    if value:
+                        props[key] = value
+                else:
+                    value = raw_input(_('%(propname)s (%(proptype)s): ')%{
+                        'propname': key.capitalize(), 'proptype': name})
+                    if value:
+                        props[key] = value
+        else:
+            props = self.props_from_args(args[1:])
+
+        # convert types
+        for propname, value in props.items():
+            try:
+                props[propname] = hyperdb.rawToHyperdb(self.db, cl, None,
+                    propname, value)
+            except hyperdb.HyperdbValueError, message:
+                raise UsageError, message
+
+        # check for the key property
+        propname = cl.getkey()
+        if propname and not props.has_key(propname):
+            raise UsageError, _('you must provide the "%(propname)s" '
+                'property.')%locals()
+
+        # do the actual create
+        try:
+            print apply(cl.create, (), props)
+        except (TypeError, IndexError, ValueError), message:
+            raise UsageError, message
+        return 0
+
+    def do_list(self, args):
+        ""'''Usage: list classname [property]
+        List the instances of a class.
+
+        Lists all instances of the given class. If the property is not
+        specified, the  "label" property is used. The label property is
+        tried in order: the key, "name", "title" and then the first
+        property, alphabetically.
+
+        With -c, -S or -s print a list of item id's if no property
+        specified.  If property specified, print list of that property
+        for every class instance.
+        '''
+        if len(args) > 2:
+            raise UsageError, _('Too many arguments supplied')
+        if len(args) < 1:
+            raise UsageError, _('Not enough arguments supplied')
+        classname = args[0]
+
+        # get the class
+        cl = self.get_class(classname)
+
+        # figure the property
+        if len(args) > 1:
+            propname = args[1]
+        else:
+            propname = cl.labelprop()
+
+        if self.separator:
+            if len(args) == 2:
+               # create a list of propnames since user specified propname
+                proplist=[]
+                for nodeid in cl.list():
+                    try:
+                        proplist.append(cl.get(nodeid, propname))
+                    except KeyError:
+                        raise UsageError, _('%(classname)s has no property '
+                            '"%(propname)s"')%locals()
+                print self.separator.join(proplist)
+            else:
+                # create a list of index id's since user didn't specify
+                # otherwise
+                print self.separator.join(cl.list())
+        else:
+            for nodeid in cl.list():
+                try:
+                    value = cl.get(nodeid, propname)
+                except KeyError:
+                    raise UsageError, _('%(classname)s has no property '
+                        '"%(propname)s"')%locals()
+                print _('%(nodeid)4s: %(value)s')%locals()
+        return 0
+
+    def do_table(self, args):
+        ""'''Usage: table classname [property[,property]*]
+        List the instances of a class in tabular form.
+
+        Lists all instances of the given class. If the properties are not
+        specified, all properties are displayed. By default, the column
+        widths are the width of the largest value. The width may be
+        explicitly defined by defining the property as "name:width".
+        For example::
+
+          roundup> table priority id,name:10
+          Id Name
+          1  fatal-bug
+          2  bug
+          3  usability
+          4  feature
+
+        Also to make the width of the column the width of the label,
+        leave a trailing : without a width on the property. For example::
+
+          roundup> table priority id,name:
+          Id Name
+          1  fata
+          2  bug
+          3  usab
+          4  feat
+
+        will result in a the 4 character wide "Name" column.
+        '''
+        if len(args) < 1:
+            raise UsageError, _('Not enough arguments supplied')
+        classname = args[0]
+
+        # get the class
+        cl = self.get_class(classname)
+
+        # figure the property names to display
+        if len(args) > 1:
+            prop_names = args[1].split(',')
+            all_props = cl.getprops()
+            for spec in prop_names:
+                if ':' in spec:
+                    try:
+                        propname, width = spec.split(':')
+                    except (ValueError, TypeError):
+                        raise UsageError, _('"%(spec)s" not name:width')%locals()
+                else:
+                    propname = spec
+                if not all_props.has_key(propname):
+                    raise UsageError, _('%(classname)s has no property '
+                        '"%(propname)s"')%locals()
+        else:
+            prop_names = cl.getprops().keys()
+
+        # now figure column widths
+        props = []
+        for spec in prop_names:
+            if ':' in spec:
+                name, width = spec.split(':')
+                if width == '':
+                    props.append((name, len(spec)))
+                else:
+                    props.append((name, int(width)))
+            else:
+               # this is going to be slow
+               maxlen = len(spec)
+               for nodeid in cl.list():
+                   curlen = len(str(cl.get(nodeid, spec)))
+                   if curlen > maxlen:
+                       maxlen = curlen
+               props.append((spec, maxlen))
+
+        # now display the heading
+        print ' '.join([name.capitalize().ljust(width) for name,width in props])
+
+        # and the table data
+        for nodeid in cl.list():
+            l = []
+            for name, width in props:
+                if name != 'id':
+                    try:
+                        value = str(cl.get(nodeid, name))
+                    except KeyError:
+                        # we already checked if the property is valid - a
+                        # KeyError here means the node just doesn't have a
+                        # value for it
+                        value = ''
+                else:
+                    value = str(nodeid)
+                f = '%%-%ds'%width
+                l.append(f%value[:width])
+            print ' '.join(l)
+        return 0
+
+    def do_history(self, args):
+        ""'''Usage: history designator
+        Show the history entries of a designator.
+
+        Lists the journal entries for the node identified by the designator.
+        '''
+        if len(args) < 1:
+            raise UsageError, _('Not enough arguments supplied')
+        try:
+            classname, nodeid = hyperdb.splitDesignator(args[0])
+        except hyperdb.DesignatorError, message:
+            raise UsageError, message
+
+        try:
+            print self.db.getclass(classname).history(nodeid)
+        except KeyError:
+            raise UsageError, _('no such class "%(classname)s"')%locals()
+        except IndexError:
+            raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
+        return 0
+
+    def do_commit(self, args):
+        ""'''Usage: commit
+        Commit changes made to the database during an interactive session.
+
+        The changes made during an interactive session are not
+        automatically written to the database - they must be committed
+        using this command.
+
+        One-off commands on the command-line are automatically committed if
+        they are successful.
+        '''
+        self.db.commit()
+        return 0
+
+    def do_rollback(self, args):
+        ""'''Usage: rollback
+        Undo all changes that are pending commit to the database.
+
+        The changes made during an interactive session are not
+        automatically written to the database - they must be committed
+        manually. This command undoes all those changes, so a commit
+        immediately after would make no changes to the database.
+        '''
+        self.db.rollback()
+        return 0
+
+    def do_retire(self, args):
+        ""'''Usage: retire designator[,designator]*
+        Retire the node specified by designator.
+
+        This action indicates that a particular node is not to be retrieved
+        by the list or find commands, and its key value may be re-used.
+        '''
+        if len(args) < 1:
+            raise UsageError, _('Not enough arguments supplied')
+        designators = args[0].split(',')
+        for designator in designators:
+            try:
+                classname, nodeid = hyperdb.splitDesignator(designator)
+            except hyperdb.DesignatorError, message:
+                raise UsageError, message
+            try:
+                self.db.getclass(classname).retire(nodeid)
+            except KeyError:
+                raise UsageError, _('no such class "%(classname)s"')%locals()
+            except IndexError:
+                raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
+        return 0
+
+    def do_restore(self, args):
+        ""'''Usage: restore designator[,designator]*
+        Restore the retired node specified by designator.
+
+        The given nodes will become available for users again.
+        '''
+        if len(args) < 1:
+            raise UsageError, _('Not enough arguments supplied')
+        designators = args[0].split(',')
+        for designator in designators:
+            try:
+                classname, nodeid = hyperdb.splitDesignator(designator)
+            except hyperdb.DesignatorError, message:
+                raise UsageError, message
+            try:
+                self.db.getclass(classname).restore(nodeid)
+            except KeyError:
+                raise UsageError, _('no such class "%(classname)s"')%locals()
+            except IndexError:
+                raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
+        return 0
+
+    def do_export(self, args):
+        ""'''Usage: export [class[,class]] export_dir
+        Export the database to colon-separated-value files.
+
+        Optionally limit the export to just the names classes.
+
+        This action exports the current data from the database into
+        colon-separated-value files that are placed in the nominated
+        destination directory.
+        '''
+        # grab the directory to export to
+        if len(args) < 1:
+            raise UsageError, _('Not enough arguments supplied')
+
+        dir = args[-1]
+
+        # get the list of classes to export
+        if len(args) == 2:
+            classes = args[0].split(',')
+        else:
+            classes = self.db.classes.keys()
+
+        class colon_separated(csv.excel):
+            delimiter = ':'
+
+        # make sure target dir exists
+        if not os.path.exists(dir):
+            os.makedirs(dir)
+
+        # do all the classes specified
+        for classname in classes:
+            cl = self.get_class(classname)
+
+            f = open(os.path.join(dir, classname+'.csv'), 'wb')
+            writer = csv.writer(f, colon_separated)
+
+            properties = cl.getprops()
+            propnames = cl.export_propnames()
+            fields = propnames[:]
+            fields.append('is retired')
+            writer.writerow(fields)
+
+            # all nodes for this class
+            for nodeid in cl.getnodeids():
+                writer.writerow(cl.export_list(propnames, nodeid))
+                if hasattr(cl, 'export_files'):
+                    cl.export_files(dir, nodeid)
+
+            # close this file
+            f.close()
+
+            # export the journals
+            jf = open(os.path.join(dir, classname+'-journals.csv'), 'wb')
+            journals = csv.writer(jf, colon_separated)
+            map(journals.writerow, cl.export_journals())
+            jf.close()
+        return 0
+
+    def do_import(self, args):
+        ""'''Usage: import import_dir
+        Import a database from the directory containing CSV files,
+        two per class to import.
+
+        The files used in the import are:
+
+        <class>.csv
+          This must define the same properties as the class (including
+          having a "header" line with those property names.)
+        <class>-journals.csv
+          This defines the journals for the items being imported.
+
+        The imported nodes will have the same nodeid as defined in the
+        import file, thus replacing any existing content.
+
+        The new nodes are added to the existing database - if you want to
+        create a new database using the imported data, then create a new
+        database (or, tediously, retire all the old data.)
+        '''
+        if len(args) < 1:
+            raise UsageError, _('Not enough arguments supplied')
+        from roundup import hyperdb
+
+        # directory to import from
+        dir = args[0]
+
+        class colon_separated(csv.excel):
+            delimiter = ':'
+
+        # import all the files
+        for file in os.listdir(dir):
+            classname, ext = os.path.splitext(file)
+            # we only care about CSV files
+            if ext != '.csv' or classname.endswith('-journals'):
+                continue
+
+            cl = self.get_class(classname)
+
+            # ensure that the properties and the CSV file headings match
+            f = open(os.path.join(dir, file), 'r')
+            reader = csv.reader(f, colon_separated)
+            file_props = None
+            maxid = 1
+            # loop through the file and create a node for each entry
+            for n, r in enumerate(reader):
+                if file_props is None:
+                    file_props = r
+                    continue
+
+                sys.stdout.write('Importing %s - %d\r'%(classname, n))
+                sys.stdout.flush()
+
+                # do the import and figure the current highest nodeid
+                nodeid = int(cl.import_list(file_props, r))
+                if hasattr(cl, 'import_files'):
+                    cl.import_files(dir, nodeid)
+                maxid = max(maxid, nodeid)
+            print
+            f.close()
+
+            # import the journals
+            f = open(os.path.join(args[0], classname + '-journals.csv'), 'r')
+            reader = csv.reader(f, colon_separated)
+            cl.import_journals(reader)
+            f.close()
+
+            # set the id counter
+            print 'setting', classname, maxid+1
+            self.db.setid(classname, str(maxid+1))
+
+        return 0
+
+    def do_pack(self, args):
+        ""'''Usage: pack period | date
+
+        Remove journal entries older than a period of time specified or
+        before a certain date.
+
+        A period is specified using the suffixes "y", "m", and "d". The
+        suffix "w" (for "week") means 7 days.
+
+              "3y" means three years
+              "2y 1m" means two years and one month
+              "1m 25d" means one month and 25 days
+              "2w 3d" means two weeks and three days
+
+        Date format is "YYYY-MM-DD" eg:
+            2001-01-01
+
+        '''
+        if len(args) <> 1:
+            raise UsageError, _('Not enough arguments supplied')
+
+        # are we dealing with a period or a date
+        value = args[0]
+        date_re = re.compile(r'''
+              (?P<date>\d\d\d\d-\d\d?-\d\d?)? # yyyy-mm-dd
+              (?P<period>(\d+y\s*)?(\d+m\s*)?(\d+d\s*)?)?
+              ''', re.VERBOSE)
+        m = date_re.match(value)
+        if not m:
+            raise ValueError, _('Invalid format')
+        m = m.groupdict()
+        if m['period']:
+            pack_before = date.Date(". - %s"%value)
+        elif m['date']:
+            pack_before = date.Date(value)
+        self.db.pack(pack_before)
+        return 0
+
+    def do_reindex(self, args, desre=re.compile('([A-Za-z]+)([0-9]+)')):
+        ""'''Usage: reindex [classname|designator]*
+        Re-generate a tracker's search indexes.
+
+        This will re-generate the search indexes for a tracker.
+        This will typically happen automatically.
+        '''
+        if args:
+            for arg in args:
+                m = desre.match(arg)
+                if m:
+                    cl = self.get_class(m.group(1))
+                    try:
+                        cl.index(m.group(2))
+                    except IndexError:
+                        raise UsageError, _('no such item "%(designator)s"')%{
+                            'designator': arg}
+                else:
+                    cl = self.get_class(arg)
+                    self.db.reindex(arg)
+        else:
+            self.db.reindex(show_progress=True)
+        return 0
+
+    def do_security(self, args):
+        ""'''Usage: security [Role name]
+        Display the Permissions available to one or all Roles.
+        '''
+        if len(args) == 1:
+            role = args[0]
+            try:
+                roles = [(args[0], self.db.security.role[args[0]])]
+            except KeyError:
+                print _('No such Role "%(role)s"')%locals()
+                return 1
+        else:
+            roles = self.db.security.role.items()
+            role = self.db.config.NEW_WEB_USER_ROLES
+            if ',' in role:
+                print _('New Web users get the Roles "%(role)s"')%locals()
+            else:
+                print _('New Web users get the Role "%(role)s"')%locals()
+            role = self.db.config.NEW_EMAIL_USER_ROLES
+            if ',' in role:
+                print _('New Email users get the Roles "%(role)s"')%locals()
+            else:
+                print _('New Email users get the Role "%(role)s"')%locals()
+        roles.sort()
+        for rolename, role in roles:
+            print _('Role "%(name)s":')%role.__dict__
+            for permission in role.permissions:
+                d = permission.__dict__
+                if permission.klass:
+                    if permission.properties:
+                        print _(' %(description)s (%(name)s for "%(klass)s"'
+                          ': %(properties)s only)')%d
+                    else:
+                        print _(' %(description)s (%(name)s for "%(klass)s" '
+                            'only)')%d
+                else:
+                    print _(' %(description)s (%(name)s)')%d
+        return 0
+
+    def run_command(self, args):
+        '''Run a single command
+        '''
+        command = args[0]
+
+        # handle help now
+        if command == 'help':
+            if len(args)>1:
+                self.do_help(args[1:])
+                return 0
+            self.do_help(['help'])
+            return 0
+        if command == 'morehelp':
+            self.do_help(['help'])
+            self.help_commands()
+            self.help_all()
+            return 0
+        if command == 'config':
+            self.do_config(args[1:])
+            return 0
+
+        # figure what the command is
+        try:
+            functions = self.commands.get(command)
+        except KeyError:
+            # not a valid command
+            print _('Unknown command "%(command)s" ("help commands" for a '
+                'list)')%locals()
+            return 1
+
+        # check for multiple matches
+        if len(functions) > 1:
+            print _('Multiple commands match "%(command)s": %(list)s')%{'command':
+                command, 'list': ', '.join([i[0] for i in functions])}
+            return 1
+        command, function = functions[0]
+
+        # make sure we have a tracker_home
+        while not self.tracker_home:
+            self.tracker_home = raw_input(_('Enter tracker home: ')).strip()
+
+        # before we open the db, we may be doing an install or init
+        if command == 'initialise':
+            try:
+                return self.do_initialise(self.tracker_home, args)
+            except UsageError, message:
+                print _('Error: %(message)s')%locals()
+                return 1
+        elif command == 'install':
+            try:
+                return self.do_install(self.tracker_home, args)
+            except UsageError, message:
+                print _('Error: %(message)s')%locals()
+                return 1
+
+        # get the tracker
+        try:
+            tracker = roundup.instance.open(self.tracker_home)
+        except ValueError, message:
+            self.tracker_home = ''
+            print _("Error: Couldn't open tracker: %(message)s")%locals()
+            return 1
+
+        # only open the database once!
+        if not self.db:
+            self.db = tracker.open('admin')
+
+        # do the command
+        ret = 0
+        try:
+            ret = function(args[1:])
+        except UsageError, message:
+            print _('Error: %(message)s')%locals()
+            print
+            print function.__doc__
+            ret = 1
+        except:
+            import traceback
+            traceback.print_exc()
+            ret = 1
+        return ret
+
+    def interactive(self):
+        '''Run in an interactive mode
+        '''
+        print _('Roundup %s ready for input.\nType "help" for help.'
+            % roundup_version)
+        try:
+            import readline
+        except ImportError:
+            print _('Note: command history and editing not available')
+
+        while 1:
+            try:
+                command = raw_input(_('roundup> '))
+            except EOFError:
+                print _('exit...')
+                break
+            if not command: continue
+            args = token.token_split(command)
+            if not args: continue
+            if args[0] in ('quit', 'exit'): break
+            self.run_command(args)
+
+        # exit.. check for transactions
+        if self.db and self.db.transactions:
+            commit = raw_input(_('There are unsaved changes. Commit them (y/N)? '))
+            if commit and commit[0].lower() == 'y':
+                self.db.commit()
+        return 0
+
+    def main(self):
+        try:
+            opts, args = getopt.getopt(sys.argv[1:], 'i:u:hcdsS:v')
+        except getopt.GetoptError, e:
+            self.usage(str(e))
+            return 1
+
+        # handle command-line args
+        self.tracker_home = os.environ.get('TRACKER_HOME', '')
+        # TODO: reinstate the user/password stuff (-u arg too)
+        name = password = ''
+        if os.environ.has_key('ROUNDUP_LOGIN'):
+            l = os.environ['ROUNDUP_LOGIN'].split(':')
+            name = l[0]
+            if len(l) > 1:
+                password = l[1]
+        self.separator = None
+        self.print_designator = 0
+        self.verbose = 0
+        for opt, arg in opts:
+            if opt == '-h':
+                self.usage()
+                return 0
+            elif opt == '-v':
+                print '%s (python %s)'%(roundup_version, sys.version.split()[0])
+                return 0
+            elif opt == '-V':
+                self.verbose = 1
+            elif opt == '-i':
+                self.tracker_home = arg
+            elif opt == '-c':
+                if self.separator != None:
+                    self.usage('Only one of -c, -S and -s may be specified')
+                    return 1
+                self.separator = ','
+            elif opt == '-S':
+                if self.separator != None:
+                    self.usage('Only one of -c, -S and -s may be specified')
+                    return 1
+                self.separator = arg
+            elif opt == '-s':
+                if self.separator != None:
+                    self.usage('Only one of -c, -S and -s may be specified')
+                    return 1
+                self.separator = ' '
+            elif opt == '-d':
+                self.print_designator = 1
+
+        # if no command - go interactive
+        # wrap in a try/finally so we always close off the db
+        ret = 0
+        try:
+            if not args:
+                self.interactive()
+            else:
+                ret = self.run_command(args)
+                if self.db: self.db.commit()
+            return ret
+        finally:
+            if self.db:
+                self.db.close()
+
+if __name__ == '__main__':
+    tool = AdminTool()
+    sys.exit(tool.main())
+
+# vim: set filetype=python sts=4 sw=4 et si :

Added: tracker/vendor/roundup/current/roundup/backends/.cvsignore
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/backends/.cvsignore	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,3 @@
+*.pyc
+*.pyo
+*.cover

Added: tracker/vendor/roundup/current/roundup/backends/__init__.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/backends/__init__.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,87 @@
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+#
+# $Id: __init__.py,v 1.36 2006/01/25 03:24:09 richard Exp $
+
+'''Container for the hyperdb storage backend implementations.
+'''
+__docformat__ = 'restructuredtext'
+
+import sys
+
+# These names are used to suppress import errors.
+# If get_backend raises an ImportError with appropriate
+# module name, have_backend quietly returns False.
+# Otherwise the error is reraised.
+_modules = {
+    'mysql': 'MySQLdb',
+    'postgresql': 'psycopg',
+    'tsearch2': 'psycopg',
+}
+
+def get_backend(name):
+    '''Get a specific backend by name.'''
+    vars = globals()
+    # if requested backend has been imported yet, return current instance
+    if vars.has_key(name):
+        return vars[name]
+    # import the backend module
+    module_name = 'back_%s' % name
+    try:
+        module = __import__(module_name, vars)
+    except:
+        # import failed, but in versions prior to 2.4, a (broken)
+        # module is left in sys.modules and package globals;
+        # subsequent imports would succeed and get the broken module.
+        # This no longer happens in Python 2.4 and later.
+        if sys.version_info < (2, 4):
+            del sys.modules['.'.join((__name__, module_name))]
+            del vars[module_name]
+        raise
+    else:
+        vars[name] = module
+        return module
+
+def have_backend(name):
+    '''Is backend "name" available?'''
+    if name == 'tsearch2':
+        # currently not working
+        return 0
+    try:
+        get_backend(name)
+        return 1
+    except ImportError, e:
+        global _modules
+        if not str(e).startswith('No module named %s'
+                % _modules.get(name, name)):
+            raise
+    return 0
+
+def list_backends():
+    '''List all available backend names.
+
+    This function has side-effect of registering backward-compatible
+    globals for all available backends.
+
+    '''
+    l = []
+    for name in 'anydbm', 'mysql', 'sqlite', 'metakit', 'postgresql':
+        if have_backend(name):
+            l.append(name)
+    return l
+
+# vim: set filetype=python sts=4 sw=4 et si :

Added: tracker/vendor/roundup/current/roundup/backends/back_anydbm.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/backends/back_anydbm.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,2158 @@
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+#
+#$Id: back_anydbm.py,v 1.199 2006/04/27 04:59:37 richard Exp $
+'''This module defines a backend that saves the hyperdatabase in a
+database chosen by anydbm. It is guaranteed to always be available in python
+versions >2.1.1 (the dumbdbm fallback in 2.1.1 and earlier has several
+serious bugs, and is not available)
+'''
+__docformat__ = 'restructuredtext'
+
+try:
+    import anydbm, sys
+    # dumbdbm only works in python 2.1.2+
+    if sys.version_info < (2,1,2):
+        import dumbdbm
+        assert anydbm._defaultmod != dumbdbm
+        del dumbdbm
+except AssertionError:
+    print "WARNING: you should upgrade to python 2.1.3"
+
+import whichdb, os, marshal, re, weakref, string, copy, time, shutil, logging
+
+from roundup import hyperdb, date, password, roundupdb, security, support
+from roundup.backends import locking
+from roundup.i18n import _
+
+from blobfiles import FileStorage
+from sessions_dbm import Sessions, OneTimeKeys
+
+try:
+    from indexer_xapian import Indexer
+except ImportError:
+    from indexer_dbm import Indexer
+
+def db_exists(config):
+    # check for the user db
+    for db in 'nodes.user nodes.user.db'.split():
+        if os.path.exists(os.path.join(config.DATABASE, db)):
+            return 1
+    return 0
+
+def db_nuke(config):
+    shutil.rmtree(config.DATABASE)
+
+#
+# Now the database
+#
+class Database(FileStorage, hyperdb.Database, roundupdb.Database):
+    '''A database for storing records containing flexible data types.
+
+    Transaction stuff TODO:
+
+    - check the timestamp of the class file and nuke the cache if it's
+      modified. Do some sort of conflict checking on the dirty stuff.
+    - perhaps detect write collisions (related to above)?
+    '''
+    def __init__(self, config, journaltag=None):
+        '''Open a hyperdatabase given a specifier to some storage.
+
+        The 'storagelocator' is obtained from config.DATABASE.
+        The meaning of 'storagelocator' depends on the particular
+        implementation of the hyperdatabase.  It could be a file name,
+        a directory path, a socket descriptor for a connection to a
+        database over the network, etc.
+
+        The 'journaltag' is a token that will be attached to the journal
+        entries for any edits done on the database.  If 'journaltag' is
+        None, the database is opened in read-only mode: the Class.create(),
+        Class.set(), Class.retire(), and Class.restore() methods are
+        disabled.
+        '''
+        self.config, self.journaltag = config, journaltag
+        self.dir = config.DATABASE
+        self.classes = {}
+        self.cache = {}         # cache of nodes loaded or created
+        self.stats = {'cache_hits': 0, 'cache_misses': 0, 'get_items': 0,
+            'filtering': 0}
+        self.dirtynodes = {}    # keep track of the dirty nodes by class
+        self.newnodes = {}      # keep track of the new nodes by class
+        self.destroyednodes = {}# keep track of the destroyed nodes by class
+        self.transactions = []
+        self.indexer = Indexer(self)
+        self.security = security.Security(self)
+        os.umask(config.UMASK)
+
+        # lock it
+        lockfilenm = os.path.join(self.dir, 'lock')
+        self.lockfile = locking.acquire_lock(lockfilenm)
+        self.lockfile.write(str(os.getpid()))
+        self.lockfile.flush()
+
+    def post_init(self):
+        '''Called once the schema initialisation has finished.
+        '''
+        # reindex the db if necessary
+        if self.indexer.should_reindex():
+            self.reindex()
+
+    def refresh_database(self):
+        """Rebuild the database
+        """
+        self.reindex()
+
+    def getSessionManager(self):
+        return Sessions(self)
+
+    def getOTKManager(self):
+        return OneTimeKeys(self)
+
+    def reindex(self, classname=None, show_progress=False):
+        if classname:
+            classes = [self.getclass(classname)]
+        else:
+            classes = self.classes.values()
+        for klass in classes:
+            if show_progress:
+                for nodeid in support.Progress('Reindex %s'%klass.classname,
+                        klass.list()):
+                    klass.index(nodeid)
+            else:
+                for nodeid in klass.list():
+                    klass.index(nodeid)
+        self.indexer.save_index()
+
+    def __repr__(self):
+        return '<back_anydbm instance at %x>'%id(self)
+
+    #
+    # Classes
+    #
+    def __getattr__(self, classname):
+        '''A convenient way of calling self.getclass(classname).'''
+        if self.classes.has_key(classname):
+            return self.classes[classname]
+        raise AttributeError, classname
+
+    def addclass(self, cl):
+        cn = cl.classname
+        if self.classes.has_key(cn):
+            raise ValueError, cn
+        self.classes[cn] = cl
+
+        # add default Edit and View permissions
+        self.security.addPermission(name="Create", klass=cn,
+            description="User is allowed to create "+cn)
+        self.security.addPermission(name="Edit", klass=cn,
+            description="User is allowed to edit "+cn)
+        self.security.addPermission(name="View", klass=cn,
+            description="User is allowed to access "+cn)
+
+    def getclasses(self):
+        '''Return a list of the names of all existing classes.'''
+        l = self.classes.keys()
+        l.sort()
+        return l
+
+    def getclass(self, classname):
+        '''Get the Class object representing a particular class.
+
+        If 'classname' is not a valid class name, a KeyError is raised.
+        '''
+        try:
+            return self.classes[classname]
+        except KeyError:
+            raise KeyError, 'There is no class called "%s"'%classname
+
+    #
+    # Class DBs
+    #
+    def clear(self):
+        '''Delete all database contents
+        '''
+        logging.getLogger('hyperdb').info('clear')
+        for cn in self.classes.keys():
+            for dummy in 'nodes', 'journals':
+                path = os.path.join(self.dir, 'journals.%s'%cn)
+                if os.path.exists(path):
+                    os.remove(path)
+                elif os.path.exists(path+'.db'):    # dbm appends .db
+                    os.remove(path+'.db')
+        # reset id sequences
+        path = os.path.join(os.getcwd(), self.dir, '_ids')
+        if os.path.exists(path):
+            os.remove(path)
+        elif os.path.exists(path+'.db'):    # dbm appends .db
+            os.remove(path+'.db')
+
+    def getclassdb(self, classname, mode='r'):
+        ''' grab a connection to the class db that will be used for
+            multiple actions
+        '''
+        return self.opendb('nodes.%s'%classname, mode)
+
+    def determine_db_type(self, path):
+        ''' determine which DB wrote the class file
+        '''
+        db_type = ''
+        if os.path.exists(path):
+            db_type = whichdb.whichdb(path)
+            if not db_type:
+                raise hyperdb.DatabaseError, "Couldn't identify database type"
+        elif os.path.exists(path+'.db'):
+            # if the path ends in '.db', it's a dbm database, whether
+            # anydbm says it's dbhash or not!
+            db_type = 'dbm'
+        return db_type
+
+    def opendb(self, name, mode):
+        '''Low-level database opener that gets around anydbm/dbm
+           eccentricities.
+        '''
+        # figure the class db type
+        path = os.path.join(os.getcwd(), self.dir, name)
+        db_type = self.determine_db_type(path)
+
+        # new database? let anydbm pick the best dbm
+        if not db_type:
+            if __debug__:
+                logging.getLogger('hyperdb').debug("opendb anydbm.open(%r, 'c')"%path)
+            return anydbm.open(path, 'c')
+
+        # open the database with the correct module
+        try:
+            dbm = __import__(db_type)
+        except ImportError:
+            raise hyperdb.DatabaseError, \
+                "Couldn't open database - the required module '%s'"\
+                " is not available"%db_type
+        if __debug__:
+            logging.getLogger('hyperdb').debug("opendb %r.open(%r, %r)"%(db_type, path,
+                mode))
+        return dbm.open(path, mode)
+
+    #
+    # Node IDs
+    #
+    def newid(self, classname):
+        ''' Generate a new id for the given class
+        '''
+        # open the ids DB - create if if doesn't exist
+        db = self.opendb('_ids', 'c')
+        if db.has_key(classname):
+            newid = db[classname] = str(int(db[classname]) + 1)
+        else:
+            # the count() bit is transitional - older dbs won't start at 1
+            newid = str(self.getclass(classname).count()+1)
+            db[classname] = newid
+        db.close()
+        return newid
+
+    def setid(self, classname, setid):
+        ''' Set the id counter: used during import of database
+        '''
+        # open the ids DB - create if if doesn't exist
+        db = self.opendb('_ids', 'c')
+        db[classname] = str(setid)
+        db.close()
+
+    #
+    # Nodes
+    #
+    def addnode(self, classname, nodeid, node):
+        ''' add the specified node to its class's db
+        '''
+        # we'll be supplied these props if we're doing an import
+        if not node.has_key('creator'):
+            # add in the "calculated" properties (dupe so we don't affect
+            # calling code's node assumptions)
+            node = node.copy()
+            node['creator'] = self.getuid()
+            node['actor'] = self.getuid()
+            node['creation'] = node['activity'] = date.Date()
+
+        self.newnodes.setdefault(classname, {})[nodeid] = 1
+        self.cache.setdefault(classname, {})[nodeid] = node
+        self.savenode(classname, nodeid, node)
+
+    def setnode(self, classname, nodeid, node):
+        ''' change the specified node
+        '''
+        self.dirtynodes.setdefault(classname, {})[nodeid] = 1
+
+        # can't set without having already loaded the node
+        self.cache[classname][nodeid] = node
+        self.savenode(classname, nodeid, node)
+
+    def savenode(self, classname, nodeid, node):
+        ''' perform the saving of data specified by the set/addnode
+        '''
+        if __debug__:
+            logging.getLogger('hyperdb').debug('save %s%s %r'%(classname, nodeid, node))
+        self.transactions.append((self.doSaveNode, (classname, nodeid, node)))
+
+    def getnode(self, classname, nodeid, db=None, cache=1):
+        ''' get a node from the database
+
+            Note the "cache" parameter is not used, and exists purely for
+            backward compatibility!
+        '''
+        # try the cache
+        cache_dict = self.cache.setdefault(classname, {})
+        if cache_dict.has_key(nodeid):
+            if __debug__:
+                logging.getLogger('hyperdb').debug('get %s%s cached'%(classname, nodeid))
+                self.stats['cache_hits'] += 1
+            return cache_dict[nodeid]
+
+        if __debug__:
+            self.stats['cache_misses'] += 1
+            start_t = time.time()
+            logging.getLogger('hyperdb').debug('get %s%s'%(classname, nodeid))
+
+        # get from the database and save in the cache
+        if db is None:
+            db = self.getclassdb(classname)
+        if not db.has_key(nodeid):
+            raise IndexError, "no such %s %s"%(classname, nodeid)
+
+        # check the uncommitted, destroyed nodes
+        if (self.destroyednodes.has_key(classname) and
+                self.destroyednodes[classname].has_key(nodeid)):
+            raise IndexError, "no such %s %s"%(classname, nodeid)
+
+        # decode
+        res = marshal.loads(db[nodeid])
+
+        # reverse the serialisation
+        res = self.unserialise(classname, res)
+
+        # store off in the cache dict
+        if cache:
+            cache_dict[nodeid] = res
+
+        if __debug__:
+            self.stats['get_items'] += (time.time() - start_t)
+
+        return res
+
+    def destroynode(self, classname, nodeid):
+        '''Remove a node from the database. Called exclusively by the
+           destroy() method on Class.
+        '''
+        logging.getLogger('hyperdb').info('destroy %s%s'%(classname, nodeid))
+
+        # remove from cache and newnodes if it's there
+        if (self.cache.has_key(classname) and
+                self.cache[classname].has_key(nodeid)):
+            del self.cache[classname][nodeid]
+        if (self.newnodes.has_key(classname) and
+                self.newnodes[classname].has_key(nodeid)):
+            del self.newnodes[classname][nodeid]
+
+        # see if there's any obvious commit actions that we should get rid of
+        for entry in self.transactions[:]:
+            if entry[1][:2] == (classname, nodeid):
+                self.transactions.remove(entry)
+
+        # add to the destroyednodes map
+        self.destroyednodes.setdefault(classname, {})[nodeid] = 1
+
+        # add the destroy commit action
+        self.transactions.append((self.doDestroyNode, (classname, nodeid)))
+
+    def serialise(self, classname, node):
+        '''Copy the node contents, converting non-marshallable data into
+           marshallable data.
+        '''
+        properties = self.getclass(classname).getprops()
+        d = {}
+        for k, v in node.items():
+            if k == self.RETIRED_FLAG:
+                d[k] = v
+                continue
+
+            # if the property doesn't exist then we really don't care
+            if not properties.has_key(k):
+                continue
+
+            # get the property spec
+            prop = properties[k]
+
+            if isinstance(prop, hyperdb.Password) and v is not None:
+                d[k] = str(v)
+            elif isinstance(prop, hyperdb.Date) and v is not None:
+                d[k] = v.serialise()
+            elif isinstance(prop, hyperdb.Interval) and v is not None:
+                d[k] = v.serialise()
+            else:
+                d[k] = v
+        return d
+
+    def unserialise(self, classname, node):
+        '''Decode the marshalled node data
+        '''
+        properties = self.getclass(classname).getprops()
+        d = {}
+        for k, v in node.items():
+            # if the property doesn't exist, or is the "retired" flag then
+            # it won't be in the properties dict
+            if not properties.has_key(k):
+                d[k] = v
+                continue
+
+            # get the property spec
+            prop = properties[k]
+
+            if isinstance(prop, hyperdb.Date) and v is not None:
+                d[k] = date.Date(v)
+            elif isinstance(prop, hyperdb.Interval) and v is not None:
+                d[k] = date.Interval(v)
+            elif isinstance(prop, hyperdb.Password) and v is not None:
+                p = password.Password()
+                p.unpack(v)
+                d[k] = p
+            else:
+                d[k] = v
+        return d
+
+    def hasnode(self, classname, nodeid, db=None):
+        ''' determine if the database has a given node
+        '''
+        # try the cache
+        cache = self.cache.setdefault(classname, {})
+        if cache.has_key(nodeid):
+            return 1
+
+        # not in the cache - check the database
+        if db is None:
+            db = self.getclassdb(classname)
+        res = db.has_key(nodeid)
+        return res
+
+    def countnodes(self, classname, db=None):
+        count = 0
+
+        # include the uncommitted nodes
+        if self.newnodes.has_key(classname):
+            count += len(self.newnodes[classname])
+        if self.destroyednodes.has_key(classname):
+            count -= len(self.destroyednodes[classname])
+
+        # and count those in the DB
+        if db is None:
+            db = self.getclassdb(classname)
+        count = count + len(db.keys())
+        return count
+
+
+    #
+    # Files - special node properties
+    # inherited from FileStorage
+
+    #
+    # Journal
+    #
+    def addjournal(self, classname, nodeid, action, params, creator=None,
+            creation=None):
+        ''' Journal the Action
+        'action' may be:
+
+            'create' or 'set' -- 'params' is a dictionary of property values
+            'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
+            'retire' -- 'params' is None
+
+            'creator' -- the user performing the action, which defaults to
+            the current user.
+        '''
+        if __debug__:
+            logging.getLogger('hyperdb').debug('addjournal %s%s %s %r %s %r'%(classname,
+                nodeid, action, params, creator, creation))
+        if creator is None:
+            creator = self.getuid()
+        self.transactions.append((self.doSaveJournal, (classname, nodeid,
+            action, params, creator, creation)))
+
+    def setjournal(self, classname, nodeid, journal):
+        '''Set the journal to the "journal" list.'''
+        if __debug__:
+            logging.getLogger('hyperdb').debug('setjournal %s%s %r'%(classname,
+                nodeid, journal))
+        self.transactions.append((self.doSetJournal, (classname, nodeid,
+            journal)))
+
+    def getjournal(self, classname, nodeid):
+        ''' get the journal for id
+
+            Raise IndexError if the node doesn't exist (as per history()'s
+            API)
+        '''
+        # our journal result
+        res = []
+
+        # add any journal entries for transactions not committed to the
+        # database
+        for method, args in self.transactions:
+            if method != self.doSaveJournal:
+                continue
+            (cache_classname, cache_nodeid, cache_action, cache_params,
+                cache_creator, cache_creation) = args
+            if cache_classname == classname and cache_nodeid == nodeid:
+                if not cache_creator:
+                    cache_creator = self.getuid()
+                if not cache_creation:
+                    cache_creation = date.Date()
+                res.append((cache_nodeid, cache_creation, cache_creator,
+                    cache_action, cache_params))
+
+        # attempt to open the journal - in some rare cases, the journal may
+        # not exist
+        try:
+            db = self.opendb('journals.%s'%classname, 'r')
+        except anydbm.error, error:
+            if str(error) == "need 'c' or 'n' flag to open new db":
+                raise IndexError, 'no such %s %s'%(classname, nodeid)
+            elif error.args[0] != 2:
+                # this isn't a "not found" error, be alarmed!
+                raise
+            if res:
+                # we have unsaved journal entries, return them
+                return res
+            raise IndexError, 'no such %s %s'%(classname, nodeid)
+        try:
+            journal = marshal.loads(db[nodeid])
+        except KeyError:
+            db.close()
+            if res:
+                # we have some unsaved journal entries, be happy!
+                return res
+            raise IndexError, 'no such %s %s'%(classname, nodeid)
+        db.close()
+
+        # add all the saved journal entries for this node
+        for nodeid, date_stamp, user, action, params in journal:
+            res.append((nodeid, date.Date(date_stamp), user, action, params))
+        return res
+
+    def pack(self, pack_before):
+        ''' Delete all journal entries except "create" before 'pack_before'.
+        '''
+        pack_before = pack_before.serialise()
+        for classname in self.getclasses():
+            packed = 0
+            # get the journal db
+            db_name = 'journals.%s'%classname
+            path = os.path.join(os.getcwd(), self.dir, classname)
+            db_type = self.determine_db_type(path)
+            db = self.opendb(db_name, 'w')
+
+            for key in db.keys():
+                # get the journal for this db entry
+                journal = marshal.loads(db[key])
+                l = []
+                last_set_entry = None
+                for entry in journal:
+                    # unpack the entry
+                    (nodeid, date_stamp, self.journaltag, action,
+                        params) = entry
+                    # if the entry is after the pack date, _or_ the initial
+                    # create entry, then it stays
+                    if date_stamp > pack_before or action == 'create':
+                        l.append(entry)
+                    else:
+                        packed += 1
+                db[key] = marshal.dumps(l)
+
+                logging.getLogger('hyperdb').info('packed %d %s items'%(packed,
+                    classname))
+
+            if db_type == 'gdbm':
+                db.reorganize()
+            db.close()
+
+
+    #
+    # Basic transaction support
+    #
+    def commit(self):
+        ''' Commit the current transactions.
+        '''
+        logging.getLogger('hyperdb').info('commit %s transactions'%(
+            len(self.transactions)))
+
+        # keep a handle to all the database files opened
+        self.databases = {}
+
+        try:
+            # now, do all the transactions
+            reindex = {}
+            for method, args in self.transactions:
+                reindex[method(*args)] = 1
+        finally:
+            # make sure we close all the database files
+            for db in self.databases.values():
+                db.close()
+            del self.databases
+
+        # reindex the nodes that request it
+        for classname, nodeid in filter(None, reindex.keys()):
+            self.getclass(classname).index(nodeid)
+
+        # save the indexer state
+        self.indexer.save_index()
+
+        self.clearCache()
+
+    def clearCache(self):
+        # all transactions committed, back to normal
+        self.cache = {}
+        self.dirtynodes = {}
+        self.newnodes = {}
+        self.destroyednodes = {}
+        self.transactions = []
+
+    def getCachedClassDB(self, classname):
+        ''' get the class db, looking in our cache of databases for commit
+        '''
+        # get the database handle
+        db_name = 'nodes.%s'%classname
+        if not self.databases.has_key(db_name):
+            self.databases[db_name] = self.getclassdb(classname, 'c')
+        return self.databases[db_name]
+
+    def doSaveNode(self, classname, nodeid, node):
+        db = self.getCachedClassDB(classname)
+
+        # now save the marshalled data
+        db[nodeid] = marshal.dumps(self.serialise(classname, node))
+
+        # return the classname, nodeid so we reindex this content
+        return (classname, nodeid)
+
+    def getCachedJournalDB(self, classname):
+        ''' get the journal db, looking in our cache of databases for commit
+        '''
+        # get the database handle
+        db_name = 'journals.%s'%classname
+        if not self.databases.has_key(db_name):
+            self.databases[db_name] = self.opendb(db_name, 'c')
+        return self.databases[db_name]
+
+    def doSaveJournal(self, classname, nodeid, action, params, creator,
+            creation):
+        # serialise the parameters now if necessary
+        if isinstance(params, type({})):
+            if action in ('set', 'create'):
+                params = self.serialise(classname, params)
+
+        # handle supply of the special journalling parameters (usually
+        # supplied on importing an existing database)
+        journaltag = creator
+        if creation:
+            journaldate = creation.serialise()
+        else:
+            journaldate = date.Date().serialise()
+
+        # create the journal entry
+        entry = (nodeid, journaldate, journaltag, action, params)
+
+        db = self.getCachedJournalDB(classname)
+
+        # now insert the journal entry
+        if db.has_key(nodeid):
+            # append to existing
+            s = db[nodeid]
+            l = marshal.loads(s)
+            l.append(entry)
+        else:
+            l = [entry]
+
+        db[nodeid] = marshal.dumps(l)
+
+    def doSetJournal(self, classname, nodeid, journal):
+        l = []
+        for nodeid, journaldate, journaltag, action, params in journal:
+            # serialise the parameters now if necessary
+            if isinstance(params, type({})):
+                if action in ('set', 'create'):
+                    params = self.serialise(classname, params)
+            journaldate = journaldate.serialise()
+            l.append((nodeid, journaldate, journaltag, action, params))
+        db = self.getCachedJournalDB(classname)
+        db[nodeid] = marshal.dumps(l)
+
+    def doDestroyNode(self, classname, nodeid):
+        # delete from the class database
+        db = self.getCachedClassDB(classname)
+        if db.has_key(nodeid):
+            del db[nodeid]
+
+        # delete from the database
+        db = self.getCachedJournalDB(classname)
+        if db.has_key(nodeid):
+            del db[nodeid]
+
+        # return the classname, nodeid so we reindex this content
+        return (classname, nodeid)
+
+    def rollback(self):
+        ''' Reverse all actions from the current transaction.
+        '''
+        logging.getLogger('hyperdb').info('rollback %s transactions'%(
+            len(self.transactions)))
+
+        for method, args in self.transactions:
+            # delete temporary files
+            if method == self.doStoreFile:
+                self.rollbackStoreFile(*args)
+        self.cache = {}
+        self.dirtynodes = {}
+        self.newnodes = {}
+        self.destroyednodes = {}
+        self.transactions = []
+
+    def close(self):
+        ''' Nothing to do
+        '''
+        if self.lockfile is not None:
+            locking.release_lock(self.lockfile)
+            self.lockfile.close()
+            self.lockfile = None
+
+_marker = []
+class Class(hyperdb.Class):
+    '''The handle to a particular class of nodes in a hyperdatabase.'''
+
+    def enableJournalling(self):
+        '''Turn journalling on for this class
+        '''
+        self.do_journal = 1
+
+    def disableJournalling(self):
+        '''Turn journalling off for this class
+        '''
+        self.do_journal = 0
+
+    # Editing nodes:
+
+    def create(self, **propvalues):
+        '''Create a new node of this class and return its id.
+
+        The keyword arguments in 'propvalues' map property names to values.
+
+        The values of arguments must be acceptable for the types of their
+        corresponding properties or a TypeError is raised.
+
+        If this class has a key property, it must be present and its value
+        must not collide with other key strings or a ValueError is raised.
+
+        Any other properties on this class that are missing from the
+        'propvalues' dictionary are set to None.
+
+        If an id in a link or multilink property does not refer to a valid
+        node, an IndexError is raised.
+
+        These operations trigger detectors and can be vetoed.  Attempts
+        to modify the "creation" or "activity" properties cause a KeyError.
+        '''
+        self.fireAuditors('create', None, propvalues)
+        newid = self.create_inner(**propvalues)
+        self.fireReactors('create', newid, None)
+        return newid
+
+    def create_inner(self, **propvalues):
+        ''' Called by create, in-between the audit and react calls.
+        '''
+        if propvalues.has_key('id'):
+            raise KeyError, '"id" is reserved'
+
+        if self.db.journaltag is None:
+            raise hyperdb.DatabaseError, 'Database open read-only'
+
+        if propvalues.has_key('creation') or propvalues.has_key('activity'):
+            raise KeyError, '"creation" and "activity" are reserved'
+        # new node's id
+        newid = self.db.newid(self.classname)
+
+        # validate propvalues
+        num_re = re.compile('^\d+$')
+        for key, value in propvalues.items():
+            if key == self.key:
+                try:
+                    self.lookup(value)
+                except KeyError:
+                    pass
+                else:
+                    raise ValueError, 'node with key "%s" exists'%value
+
+            # try to handle this property
+            try:
+                prop = self.properties[key]
+            except KeyError:
+                raise KeyError, '"%s" has no property "%s"'%(self.classname,
+                    key)
+
+            if value is not None and isinstance(prop, hyperdb.Link):
+                if type(value) != type(''):
+                    raise ValueError, 'link value must be String'
+                link_class = self.properties[key].classname
+                # if it isn't a number, it's a key
+                if not num_re.match(value):
+                    try:
+                        value = self.db.classes[link_class].lookup(value)
+                    except (TypeError, KeyError):
+                        raise IndexError, 'new property "%s": %s not a %s'%(
+                            key, value, link_class)
+                elif not self.db.getclass(link_class).hasnode(value):
+                    raise IndexError, '%s has no node %s'%(link_class, value)
+
+                # save off the value
+                propvalues[key] = value
+
+                # register the link with the newly linked node
+                if self.do_journal and self.properties[key].do_journal:
+                    self.db.addjournal(link_class, value, 'link',
+                        (self.classname, newid, key))
+
+            elif isinstance(prop, hyperdb.Multilink):
+                if type(value) != type([]):
+                    raise TypeError, 'new property "%s" not a list of ids'%key
+
+                # clean up and validate the list of links
+                link_class = self.properties[key].classname
+                l = []
+                for entry in value:
+                    if type(entry) != type(''):
+                        raise ValueError, '"%s" multilink value (%r) '\
+                            'must contain Strings'%(key, value)
+                    # if it isn't a number, it's a key
+                    if not num_re.match(entry):
+                        try:
+                            entry = self.db.classes[link_class].lookup(entry)
+                        except (TypeError, KeyError):
+                            raise IndexError, 'new property "%s": %s not a %s'%(
+                                key, entry, self.properties[key].classname)
+                    l.append(entry)
+                value = l
+                propvalues[key] = value
+
+                # handle additions
+                for nodeid in value:
+                    if not self.db.getclass(link_class).hasnode(nodeid):
+                        raise IndexError, '%s has no node %s'%(link_class,
+                            nodeid)
+                    # register the link with the newly linked node
+                    if self.do_journal and self.properties[key].do_journal:
+                        self.db.addjournal(link_class, nodeid, 'link',
+                            (self.classname, newid, key))
+
+            elif isinstance(prop, hyperdb.String):
+                if type(value) != type('') and type(value) != type(u''):
+                    raise TypeError, 'new property "%s" not a string'%key
+                if prop.indexme:
+                    self.db.indexer.add_text((self.classname, newid, key),
+                        value)
+
+            elif isinstance(prop, hyperdb.Password):
+                if not isinstance(value, password.Password):
+                    raise TypeError, 'new property "%s" not a Password'%key
+
+            elif isinstance(prop, hyperdb.Date):
+                if value is not None and not isinstance(value, date.Date):
+                    raise TypeError, 'new property "%s" not a Date'%key
+
+            elif isinstance(prop, hyperdb.Interval):
+                if value is not None and not isinstance(value, date.Interval):
+                    raise TypeError, 'new property "%s" not an Interval'%key
+
+            elif value is not None and isinstance(prop, hyperdb.Number):
+                try:
+                    float(value)
+                except ValueError:
+                    raise TypeError, 'new property "%s" not numeric'%key
+
+            elif value is not None and isinstance(prop, hyperdb.Boolean):
+                try:
+                    int(value)
+                except ValueError:
+                    raise TypeError, 'new property "%s" not boolean'%key
+
+        # make sure there's data where there needs to be
+        for key, prop in self.properties.items():
+            if propvalues.has_key(key):
+                continue
+            if key == self.key:
+                raise ValueError, 'key property "%s" is required'%key
+            if isinstance(prop, hyperdb.Multilink):
+                propvalues[key] = []
+
+        # done
+        self.db.addnode(self.classname, newid, propvalues)
+        if self.do_journal:
+            self.db.addjournal(self.classname, newid, 'create', {})
+
+        return newid
+
+    def get(self, nodeid, propname, default=_marker, cache=1):
+        '''Get the value of a property on an existing node of this class.
+
+        'nodeid' must be the id of an existing node of this class or an
+        IndexError is raised.  'propname' must be the name of a property
+        of this class or a KeyError is raised.
+
+        'cache' exists for backward compatibility, and is not used.
+
+        Attempts to get the "creation" or "activity" properties should
+        do the right thing.
+        '''
+        if propname == 'id':
+            return nodeid
+
+        # get the node's dict
+        d = self.db.getnode(self.classname, nodeid)
+
+        # check for one of the special props
+        if propname == 'creation':
+            if d.has_key('creation'):
+                return d['creation']
+            if not self.do_journal:
+                raise ValueError, 'Journalling is disabled for this class'
+            journal = self.db.getjournal(self.classname, nodeid)
+            if journal:
+                return self.db.getjournal(self.classname, nodeid)[0][1]
+            else:
+                # on the strange chance that there's no journal
+                return date.Date()
+        if propname == 'activity':
+            if d.has_key('activity'):
+                return d['activity']
+            if not self.do_journal:
+                raise ValueError, 'Journalling is disabled for this class'
+            journal = self.db.getjournal(self.classname, nodeid)
+            if journal:
+                return self.db.getjournal(self.classname, nodeid)[-1][1]
+            else:
+                # on the strange chance that there's no journal
+                return date.Date()
+        if propname == 'creator':
+            if d.has_key('creator'):
+                return d['creator']
+            if not self.do_journal:
+                raise ValueError, 'Journalling is disabled for this class'
+            journal = self.db.getjournal(self.classname, nodeid)
+            if journal:
+                num_re = re.compile('^\d+$')
+                value = journal[0][2]
+                if num_re.match(value):
+                    return value
+                else:
+                    # old-style "username" journal tag
+                    try:
+                        return self.db.user.lookup(value)
+                    except KeyError:
+                        # user's been retired, return admin
+                        return '1'
+            else:
+                return self.db.getuid()
+        if propname == 'actor':
+            if d.has_key('actor'):
+                return d['actor']
+            if not self.do_journal:
+                raise ValueError, 'Journalling is disabled for this class'
+            journal = self.db.getjournal(self.classname, nodeid)
+            if journal:
+                num_re = re.compile('^\d+$')
+                value = journal[-1][2]
+                if num_re.match(value):
+                    return value
+                else:
+                    # old-style "username" journal tag
+                    try:
+                        return self.db.user.lookup(value)
+                    except KeyError:
+                        # user's been retired, return admin
+                        return '1'
+            else:
+                return self.db.getuid()
+
+        # get the property (raises KeyErorr if invalid)
+        prop = self.properties[propname]
+
+        if not d.has_key(propname):
+            if default is _marker:
+                if isinstance(prop, hyperdb.Multilink):
+                    return []
+                else:
+                    return None
+            else:
+                return default
+
+        # return a dupe of the list so code doesn't get confused
+        if isinstance(prop, hyperdb.Multilink):
+            return d[propname][:]
+
+        return d[propname]
+
+    def set(self, nodeid, **propvalues):
+        '''Modify a property on an existing node of this class.
+
+        'nodeid' must be the id of an existing node of this class or an
+        IndexError is raised.
+
+        Each key in 'propvalues' must be the name of a property of this
+        class or a KeyError is raised.
+
+        All values in 'propvalues' must be acceptable types for their
+        corresponding properties or a TypeError is raised.
+
+        If the value of the key property is set, it must not collide with
+        other key strings or a ValueError is raised.
+
+        If the value of a Link or Multilink property contains an invalid
+        node id, a ValueError is raised.
+
+        These operations trigger detectors and can be vetoed.  Attempts
+        to modify the "creation" or "activity" properties cause a KeyError.
+        '''
+        self.fireAuditors('set', nodeid, propvalues)
+        oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
+        for name,prop in self.getprops(protected=0).items():
+            if oldvalues.has_key(name):
+                continue
+            if isinstance(prop, hyperdb.Multilink):
+                oldvalues[name] = []
+            else:
+                oldvalues[name] = None
+        propvalues = self.set_inner(nodeid, **propvalues)
+        self.fireReactors('set', nodeid, oldvalues)
+        return propvalues
+
+    def set_inner(self, nodeid, **propvalues):
+        ''' Called by set, in-between the audit and react calls.
+        '''
+        if not propvalues:
+            return propvalues
+
+        if propvalues.has_key('creation') or propvalues.has_key('activity'):
+            raise KeyError, '"creation" and "activity" are reserved'
+
+        if propvalues.has_key('id'):
+            raise KeyError, '"id" is reserved'
+
+        if self.db.journaltag is None:
+            raise hyperdb.DatabaseError, 'Database open read-only'
+
+        node = self.db.getnode(self.classname, nodeid)
+        if node.has_key(self.db.RETIRED_FLAG):
+            raise IndexError
+        num_re = re.compile('^\d+$')
+
+        # if the journal value is to be different, store it in here
+        journalvalues = {}
+
+        for propname, value in propvalues.items():
+            # check to make sure we're not duplicating an existing key
+            if propname == self.key and node[propname] != value:
+                try:
+                    self.lookup(value)
+                except KeyError:
+                    pass
+                else:
+                    raise ValueError, 'node with key "%s" exists'%value
+
+            # this will raise the KeyError if the property isn't valid
+            # ... we don't use getprops() here because we only care about
+            # the writeable properties.
+            try:
+                prop = self.properties[propname]
+            except KeyError:
+                raise KeyError, '"%s" has no property named "%s"'%(
+                    self.classname, propname)
+
+            # if the value's the same as the existing value, no sense in
+            # doing anything
+            current = node.get(propname, None)
+            if value == current:
+                del propvalues[propname]
+                continue
+            journalvalues[propname] = current
+
+            # do stuff based on the prop type
+            if isinstance(prop, hyperdb.Link):
+                link_class = prop.classname
+                # if it isn't a number, it's a key
+                if value is not None and not isinstance(value, type('')):
+                    raise ValueError, 'property "%s" link value be a string'%(
+                        propname)
+                if isinstance(value, type('')) and not num_re.match(value):
+                    try:
+                        value = self.db.classes[link_class].lookup(value)
+                    except (TypeError, KeyError):
+                        raise IndexError, 'new property "%s": %s not a %s'%(
+                            propname, value, prop.classname)
+
+                if (value is not None and
+                        not self.db.getclass(link_class).hasnode(value)):
+                    raise IndexError, '%s has no node %s'%(link_class, value)
+
+                if self.do_journal and prop.do_journal:
+                    # register the unlink with the old linked node
+                    if node.has_key(propname) and node[propname] is not None:
+                        self.db.addjournal(link_class, node[propname], 'unlink',
+                            (self.classname, nodeid, propname))
+
+                    # register the link with the newly linked node
+                    if value is not None:
+                        self.db.addjournal(link_class, value, 'link',
+                            (self.classname, nodeid, propname))
+
+            elif isinstance(prop, hyperdb.Multilink):
+                if type(value) != type([]):
+                    raise TypeError, 'new property "%s" not a list of'\
+                        ' ids'%propname
+                link_class = self.properties[propname].classname
+                l = []
+                for entry in value:
+                    # if it isn't a number, it's a key
+                    if type(entry) != type(''):
+                        raise ValueError, 'new property "%s" link value ' \
+                            'must be a string'%propname
+                    if not num_re.match(entry):
+                        try:
+                            entry = self.db.classes[link_class].lookup(entry)
+                        except (TypeError, KeyError):
+                            raise IndexError, 'new property "%s": %s not a %s'%(
+                                propname, entry,
+                                self.properties[propname].classname)
+                    l.append(entry)
+                value = l
+                propvalues[propname] = value
+
+                # figure the journal entry for this property
+                add = []
+                remove = []
+
+                # handle removals
+                if node.has_key(propname):
+                    l = node[propname]
+                else:
+                    l = []
+                for id in l[:]:
+                    if id in value:
+                        continue
+                    # register the unlink with the old linked node
+                    if self.do_journal and self.properties[propname].do_journal:
+                        self.db.addjournal(link_class, id, 'unlink',
+                            (self.classname, nodeid, propname))
+                    l.remove(id)
+                    remove.append(id)
+
+                # handle additions
+                for id in value:
+                    if not self.db.getclass(link_class).hasnode(id):
+                        raise IndexError, '%s has no node %s'%(link_class, id)
+                    if id in l:
+                        continue
+                    # register the link with the newly linked node
+                    if self.do_journal and self.properties[propname].do_journal:
+                        self.db.addjournal(link_class, id, 'link',
+                            (self.classname, nodeid, propname))
+                    l.append(id)
+                    add.append(id)
+
+                # figure the journal entry
+                l = []
+                if add:
+                    l.append(('+', add))
+                if remove:
+                    l.append(('-', remove))
+                if l:
+                    journalvalues[propname] = tuple(l)
+
+            elif isinstance(prop, hyperdb.String):
+                if value is not None and type(value) != type('') and type(value) != type(u''):
+                    raise TypeError, 'new property "%s" not a string'%propname
+                if prop.indexme:
+                    self.db.indexer.add_text((self.classname, nodeid, propname),
+                        value)
+
+            elif isinstance(prop, hyperdb.Password):
+                if not isinstance(value, password.Password):
+                    raise TypeError, 'new property "%s" not a Password'%propname
+                propvalues[propname] = value
+
+            elif value is not None and isinstance(prop, hyperdb.Date):
+                if not isinstance(value, date.Date):
+                    raise TypeError, 'new property "%s" not a Date'% propname
+                propvalues[propname] = value
+
+            elif value is not None and isinstance(prop, hyperdb.Interval):
+                if not isinstance(value, date.Interval):
+                    raise TypeError, 'new property "%s" not an '\
+                        'Interval'%propname
+                propvalues[propname] = value
+
+            elif value is not None and isinstance(prop, hyperdb.Number):
+                try:
+                    float(value)
+                except ValueError:
+                    raise TypeError, 'new property "%s" not numeric'%propname
+
+            elif value is not None and isinstance(prop, hyperdb.Boolean):
+                try:
+                    int(value)
+                except ValueError:
+                    raise TypeError, 'new property "%s" not boolean'%propname
+
+            node[propname] = value
+
+        # nothing to do?
+        if not propvalues:
+            return propvalues
+
+        # update the activity time
+        node['activity'] = date.Date()
+        node['actor'] = self.db.getuid()
+
+        # do the set, and journal it
+        self.db.setnode(self.classname, nodeid, node)
+
+        if self.do_journal:
+            self.db.addjournal(self.classname, nodeid, 'set', journalvalues)
+
+        return propvalues
+
+    def retire(self, nodeid):
+        '''Retire a node.
+
+        The properties on the node remain available from the get() method,
+        and the node's id is never reused.
+
+        Retired nodes are not returned by the find(), list(), or lookup()
+        methods, and other nodes may reuse the values of their key properties.
+
+        These operations trigger detectors and can be vetoed.  Attempts
+        to modify the "creation" or "activity" properties cause a KeyError.
+        '''
+        if self.db.journaltag is None:
+            raise hyperdb.DatabaseError, 'Database open read-only'
+
+        self.fireAuditors('retire', nodeid, None)
+
+        node = self.db.getnode(self.classname, nodeid)
+        node[self.db.RETIRED_FLAG] = 1
+        self.db.setnode(self.classname, nodeid, node)
+        if self.do_journal:
+            self.db.addjournal(self.classname, nodeid, 'retired', None)
+
+        self.fireReactors('retire', nodeid, None)
+
+    def restore(self, nodeid):
+        '''Restpre a retired node.
+
+        Make node available for all operations like it was before retirement.
+        '''
+        if self.db.journaltag is None:
+            raise hyperdb.DatabaseError, 'Database open read-only'
+
+        node = self.db.getnode(self.classname, nodeid)
+        # check if key property was overrided
+        key = self.getkey()
+        try:
+            id = self.lookup(node[key])
+        except KeyError:
+            pass
+        else:
+            raise KeyError, "Key property (%s) of retired node clashes with \
+                existing one (%s)" % (key, node[key])
+        # Now we can safely restore node
+        self.fireAuditors('restore', nodeid, None)
+        del node[self.db.RETIRED_FLAG]
+        self.db.setnode(self.classname, nodeid, node)
+        if self.do_journal:
+            self.db.addjournal(self.classname, nodeid, 'restored', None)
+
+        self.fireReactors('restore', nodeid, None)
+
+    def is_retired(self, nodeid, cldb=None):
+        '''Return true if the node is retired.
+        '''
+        node = self.db.getnode(self.classname, nodeid, cldb)
+        if node.has_key(self.db.RETIRED_FLAG):
+            return 1
+        return 0
+
+    def destroy(self, nodeid):
+        '''Destroy a node.
+
+        WARNING: this method should never be used except in extremely rare
+                 situations where there could never be links to the node being
+                 deleted
+
+        WARNING: use retire() instead
+
+        WARNING: the properties of this node will not be available ever again
+
+        WARNING: really, use retire() instead
+
+        Well, I think that's enough warnings. This method exists mostly to
+        support the session storage of the cgi interface.
+        '''
+        if self.db.journaltag is None:
+            raise hyperdb.DatabaseError, 'Database open read-only'
+        self.db.destroynode(self.classname, nodeid)
+
+    def history(self, nodeid):
+        '''Retrieve the journal of edits on a particular node.
+
+        'nodeid' must be the id of an existing node of this class or an
+        IndexError is raised.
+
+        The returned list contains tuples of the form
+
+            (nodeid, date, tag, action, params)
+
+        'date' is a Timestamp object specifying the time of the change and
+        'tag' is the journaltag specified when the database was opened.
+        '''
+        if not self.do_journal:
+            raise ValueError, 'Journalling is disabled for this class'
+        return self.db.getjournal(self.classname, nodeid)
+
+    # Locating nodes:
+    def hasnode(self, nodeid):
+        '''Determine if the given nodeid actually exists
+        '''
+        return self.db.hasnode(self.classname, nodeid)
+
+    def setkey(self, propname):
+        '''Select a String property of this class to be the key property.
+
+        'propname' must be the name of a String property of this class or
+        None, or a TypeError is raised.  The values of the key property on
+        all existing nodes must be unique or a ValueError is raised. If the
+        property doesn't exist, KeyError is raised.
+        '''
+        prop = self.getprops()[propname]
+        if not isinstance(prop, hyperdb.String):
+            raise TypeError, 'key properties must be String'
+        self.key = propname
+
+    def getkey(self):
+        '''Return the name of the key property for this class or None.'''
+        return self.key
+
+    # TODO: set up a separate index db file for this? profile?
+    def lookup(self, keyvalue):
+        '''Locate a particular node by its key property and return its id.
+
+        If this class has no key property, a TypeError is raised.  If the
+        'keyvalue' matches one of the values for the key property among
+        the nodes in this class, the matching node's id is returned;
+        otherwise a KeyError is raised.
+        '''
+        if not self.key:
+            raise TypeError, 'No key property set for class %s'%self.classname
+        cldb = self.db.getclassdb(self.classname)
+        try:
+            for nodeid in self.getnodeids(cldb):
+                node = self.db.getnode(self.classname, nodeid, cldb)
+                if node.has_key(self.db.RETIRED_FLAG):
+                    continue
+                if not node.has_key(self.key):
+                    continue
+                if node[self.key] == keyvalue:
+                    return nodeid
+        finally:
+            cldb.close()
+        raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
+            keyvalue, self.classname)
+
+    # change from spec - allows multiple props to match
+    def find(self, **propspec):
+        '''Get the ids of nodes in this class which link to the given nodes.
+
+        'propspec' consists of keyword args propname=nodeid or
+                   propname={nodeid:1, }
+        'propname' must be the name of a property in this class, or a
+                   KeyError is raised.  That property must be a Link or
+                   Multilink property, or a TypeError is raised.
+
+        Any node in this class whose 'propname' property links to any of
+        the nodeids will be returned. Examples::
+
+            db.issue.find(messages='1')
+            db.issue.find(messages={'1':1,'3':1}, files={'7':1})
+        '''
+        propspec = propspec.items()
+        for propname, itemids in propspec:
+            # check the prop is OK
+            prop = self.properties[propname]
+            if not isinstance(prop, hyperdb.Link) and not isinstance(prop, hyperdb.Multilink):
+                raise TypeError, "'%s' not a Link/Multilink property"%propname
+
+        # ok, now do the find
+        cldb = self.db.getclassdb(self.classname)
+        l = []
+        try:
+            for id in self.getnodeids(db=cldb):
+                item = self.db.getnode(self.classname, id, db=cldb)
+                if item.has_key(self.db.RETIRED_FLAG):
+                    continue
+                for propname, itemids in propspec:
+                    if type(itemids) is not type({}):
+                        itemids = {itemids:1}
+
+                    # special case if the item doesn't have this property
+                    if not item.has_key(propname):
+                        if itemids.has_key(None):
+                            l.append(id)
+                            break
+                        continue
+
+                    # grab the property definition and its value on this item
+                    prop = self.properties[propname]
+                    value = item[propname]
+                    if isinstance(prop, hyperdb.Link) and itemids.has_key(value):
+                        l.append(id)
+                        break
+                    elif isinstance(prop, hyperdb.Multilink):
+                        hit = 0
+                        for v in value:
+                            if itemids.has_key(v):
+                                l.append(id)
+                                hit = 1
+                                break
+                        if hit:
+                            break
+        finally:
+            cldb.close()
+        return l
+
+    def stringFind(self, **requirements):
+        '''Locate a particular node by matching a set of its String
+        properties in a caseless search.
+
+        If the property is not a String property, a TypeError is raised.
+
+        The return is a list of the id of all nodes that match.
+        '''
+        for propname in requirements.keys():
+            prop = self.properties[propname]
+            if not isinstance(prop, hyperdb.String):
+                raise TypeError, "'%s' not a String property"%propname
+            requirements[propname] = requirements[propname].lower()
+        l = []
+        cldb = self.db.getclassdb(self.classname)
+        try:
+            for nodeid in self.getnodeids(cldb):
+                node = self.db.getnode(self.classname, nodeid, cldb)
+                if node.has_key(self.db.RETIRED_FLAG):
+                    continue
+                for key, value in requirements.items():
+                    if not node.has_key(key):
+                        break
+                    if node[key] is None or node[key].lower() != value:
+                        break
+                else:
+                    l.append(nodeid)
+        finally:
+            cldb.close()
+        return l
+
+    def list(self):
+        ''' Return a list of the ids of the active nodes in this class.
+        '''
+        l = []
+        cn = self.classname
+        cldb = self.db.getclassdb(cn)
+        try:
+            for nodeid in self.getnodeids(cldb):
+                node = self.db.getnode(cn, nodeid, cldb)
+                if node.has_key(self.db.RETIRED_FLAG):
+                    continue
+                l.append(nodeid)
+        finally:
+            cldb.close()
+        l.sort()
+        return l
+
+    def getnodeids(self, db=None, retired=None):
+        ''' Return a list of ALL nodeids
+
+            Set retired=None to get all nodes. Otherwise it'll get all the
+            retired or non-retired nodes, depending on the flag.
+        '''
+        res = []
+
+        # start off with the new nodes
+        if self.db.newnodes.has_key(self.classname):
+            res += self.db.newnodes[self.classname].keys()
+
+        must_close = False
+        if db is None:
+            db = self.db.getclassdb(self.classname)
+            must_close = True 
+        try:
+            res = res + db.keys()
+
+            # remove the uncommitted, destroyed nodes
+            if self.db.destroyednodes.has_key(self.classname):
+                for nodeid in self.db.destroyednodes[self.classname].keys():
+                    if db.has_key(nodeid):
+                        res.remove(nodeid)
+
+            # check retired flag
+            if retired is False or retired is True:
+                l = []
+                for nodeid in res:
+                    node = self.db.getnode(self.classname, nodeid, db)
+                    is_ret = node.has_key(self.db.RETIRED_FLAG)
+                    if retired == is_ret:
+                        l.append(nodeid)
+                res = l
+        finally:
+            if must_close:
+                db.close()
+        return res
+
+    def filter(self, search_matches, filterspec, sort=(None,None),
+            group=(None,None), num_re = re.compile('^\d+$')):
+        """Return a list of the ids of the active nodes in this class that
+        match the 'filter' spec, sorted by the group spec and then the
+        sort spec.
+
+        "filterspec" is {propname: value(s)}
+
+        "sort" and "group" are (dir, prop) where dir is '+', '-' or None
+        and prop is a prop name or None
+
+        "search_matches" is {nodeid: marker} or None
+
+        The filter must match all properties specificed. If the property
+        value to match is a list:
+        
+        1. String properties must match all elements in the list, and
+        2. Other properties must match any of the elements in the list.
+        """
+        if __debug__:
+            start_t = time.time()
+
+        cn = self.classname
+
+        # optimise filterspec
+        l = []
+        props = self.getprops()
+        LINK = 'spec:link'
+        MULTILINK = 'spec:multilink'
+        STRING = 'spec:string'
+        DATE = 'spec:date'
+        INTERVAL = 'spec:interval'
+        OTHER = 'spec:other'
+
+        for k, v in filterspec.items():
+            propclass = props[k]
+            if isinstance(propclass, hyperdb.Link):
+                if type(v) is not type([]):
+                    v = [v]
+                u = []
+                for entry in v:
+                    # the value -1 is a special "not set" sentinel
+                    if entry == '-1':
+                        entry = None
+                    u.append(entry)
+                l.append((LINK, k, u))
+            elif isinstance(propclass, hyperdb.Multilink):
+                # the value -1 is a special "not set" sentinel
+                if v in ('-1', ['-1']):
+                    v = []
+                elif type(v) is not type([]):
+                    v = [v]
+                l.append((MULTILINK, k, v))
+            elif isinstance(propclass, hyperdb.String) and k != 'id':
+                if type(v) is not type([]):
+                    v = [v]
+                for v in v:
+                    # simple glob searching
+                    v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
+                    v = v.replace('?', '.')
+                    v = v.replace('*', '.*?')
+                    l.append((STRING, k, re.compile(v, re.I)))
+            elif isinstance(propclass, hyperdb.Date):
+                try:
+                    date_rng = propclass.range_from_raw(v, self.db)
+                    l.append((DATE, k, date_rng))
+                except ValueError:
+                    # If range creation fails - ignore that search parameter
+                    pass
+            elif isinstance(propclass, hyperdb.Interval):
+                try:
+                    intv_rng = date.Range(v, date.Interval)
+                    l.append((INTERVAL, k, intv_rng))
+                except ValueError:
+                    # If range creation fails - ignore that search parameter
+                    pass
+
+            elif isinstance(propclass, hyperdb.Boolean):
+                if type(v) != type([]):
+                    v = v.split(',')
+                bv = []
+                for val in v:
+                    if type(val) is type(''):
+                        bv.append(val.lower() in ('yes', 'true', 'on', '1'))
+                    else:
+                        bv.append(val)
+                l.append((OTHER, k, bv))
+
+            elif k == 'id':
+                if type(v) != type([]):
+                    v = v.split(',')
+                l.append((OTHER, k, [str(int(val)) for val in v]))
+
+            elif isinstance(propclass, hyperdb.Number):
+                if type(v) != type([]):
+                    v = v.split(',')
+                l.append((OTHER, k, [float(val) for val in v]))
+
+        filterspec = l
+
+        # now, find all the nodes that are active and pass filtering
+        matches = []
+        cldb = self.db.getclassdb(cn)
+        t = 0
+        try:
+            # TODO: only full-scan once (use items())
+            for nodeid in self.getnodeids(cldb):
+                node = self.db.getnode(cn, nodeid, cldb)
+                if node.has_key(self.db.RETIRED_FLAG):
+                    continue
+                # apply filter
+                for t, k, v in filterspec:
+                    # handle the id prop
+                    if k == 'id':
+                        if nodeid not in v:
+                            break
+                        continue
+
+                    # get the node value
+                    nv = node.get(k, None)
+
+                    match = 0
+
+                    # now apply the property filter
+                    if t == LINK:
+                        # link - if this node's property doesn't appear in the
+                        # filterspec's nodeid list, skip it
+                        match = nv in v
+                    elif t == MULTILINK:
+                        # multilink - if any of the nodeids required by the
+                        # filterspec aren't in this node's property, then skip
+                        # it
+                        nv = node.get(k, [])
+
+                        # check for matching the absence of multilink values
+                        if not v:
+                            match = not nv
+                        else:
+                            # othewise, make sure this node has each of the
+                            # required values
+                            for want in v:
+                                if want in nv:
+                                    match = 1
+                                    break
+                    elif t == STRING:
+                        if nv is None:
+                            nv = ''
+                        # RE search
+                        match = v.search(nv)
+                    elif t == DATE or t == INTERVAL:
+                        if nv is None:
+                            match = v is None
+                        else:
+                            if v.to_value:
+                                if v.from_value <= nv and v.to_value >= nv:
+                                    match = 1
+                            else:
+                                if v.from_value <= nv:
+                                    match = 1
+                    elif t == OTHER:
+                        # straight value comparison for the other types
+                        match = nv in v
+                    if not match:
+                        break
+                else:
+                    matches.append([nodeid, node])
+
+            # filter based on full text search
+            if search_matches is not None:
+                k = []
+                for v in matches:
+                    if search_matches.has_key(v[0]):
+                        k.append(v)
+                matches = k
+
+            # always sort by id if no other sort is specified
+            if sort == (None, None):
+                sort = ('+', 'id')
+
+            # add sorting information to the match entries
+            directions = []
+            JPROPS = {'actor':1, 'activity':1, 'creator':1, 'creation':1}
+            for dir, prop in sort, group:
+                if dir is None or prop is None:
+                    continue
+                directions.append(dir)
+                propclass = props[prop]
+                try:
+                    # cache the opened link class db, if needed.
+                    lcldb = None
+                    # cache the linked class items too
+                    lcache = {}
+
+                    for entry in matches:
+                        itemid = entry[-2]
+                        item = entry[-1]
+                        # handle the properties that might be "faked"
+                        # also, handle possible missing properties
+                        try:
+                            v = item[prop]
+                        except KeyError:
+                            if JPROPS.has_key(prop):
+                                # force lookup of the special journal prop
+                                v = self.get(itemid, prop)
+                            else:
+                                # the node doesn't have a value for this
+                                # property
+                                if isinstance(propclass, hyperdb.Multilink): v = []
+                                else: v = None
+                                entry.insert(0, v)
+                                continue
+
+                        # missing (None) values are always sorted first
+                        if v is None:
+                            entry.insert(0, v)
+                            continue
+
+                        if isinstance(propclass, hyperdb.String):
+                            # it might be a string that's really an integer
+                            try: tv = int(v)
+                            except: v = v.lower()
+                            else: v = tv
+                        elif isinstance(propclass, hyperdb.Link):
+                            lcn = propclass.classname
+                            link = self.db.classes[lcn]
+                            key = link.orderprop()
+                            if key!='id':
+                                if not lcache.has_key(v):
+                                    # open the link class db if it's not already
+                                    if lcldb is None:
+                                        lcldb = self.db.getclassdb(lcn)
+                                    lcache[v] = self.db.getnode(lcn, v, lcldb)
+                                v = lcache[v][key]
+                        entry.insert(0, v)
+                finally:
+                    # if we opened the link class db, close it now
+                    if lcldb is not None:
+                        lcldb.close()
+                del lcache
+        finally:
+            cldb.close()
+
+        # sort vals are inserted, but directions are appended, so reverse
+        directions.reverse()
+
+        if '-' in directions:
+            # one or more of the sort specs is in reverse order, so we have
+            # to use this icky function to sort
+            def sortfun(a, b, directions=directions, n=range(len(directions))):
+                for i in n:
+                    if not cmp(a[i], b[i]):
+                        continue
+                    if directions[i] == '+':
+                        # compare in the usual, ascending direction
+                        return cmp(a[i],b[i])
+                    else:
+                        # compare in the reverse, descending direction
+                        return cmp(b[i],a[i])
+                # for consistency, sort by the id if the items are equal
+                return cmp(a[-2], b[-2])
+            matches.sort(sortfun)
+        else:
+            # sorting is in the normal, ascending direction
+            matches.sort()
+
+        # pull the id out of the individual entries
+        matches = [entry[-2] for entry in matches]
+        if __debug__:
+            self.db.stats['filtering'] += (time.time() - start_t)
+        return matches
+
+    def count(self):
+        '''Get the number of nodes in this class.
+
+        If the returned integer is 'numnodes', the ids of all the nodes
+        in this class run from 1 to numnodes, and numnodes+1 will be the
+        id of the next node to be created in this class.
+        '''
+        return self.db.countnodes(self.classname)
+
+    # Manipulating properties:
+
+    def getprops(self, protected=1):
+        '''Return a dictionary mapping property names to property objects.
+           If the "protected" flag is true, we include protected properties -
+           those which may not be modified.
+
+           In addition to the actual properties on the node, these
+           methods provide the "creation" and "activity" properties. If the
+           "protected" flag is true, we include protected properties - those
+           which may not be modified.
+        '''
+        d = self.properties.copy()
+        if protected:
+            d['id'] = hyperdb.String()
+            d['creation'] = hyperdb.Date()
+            d['activity'] = hyperdb.Date()
+            d['creator'] = hyperdb.Link('user')
+            d['actor'] = hyperdb.Link('user')
+        return d
+
+    def addprop(self, **properties):
+        '''Add properties to this class.
+
+        The keyword arguments in 'properties' must map names to property
+        objects, or a TypeError is raised.  None of the keys in 'properties'
+        may collide with the names of existing properties, or a ValueError
+        is raised before any properties have been added.
+        '''
+        for key in properties.keys():
+            if self.properties.has_key(key):
+                raise ValueError, key
+        self.properties.update(properties)
+
+    def index(self, nodeid):
+        ''' Add (or refresh) the node to search indexes '''
+        # find all the String properties that have indexme
+        for prop, propclass in self.getprops().items():
+            if isinstance(propclass, hyperdb.String) and propclass.indexme:
+                # index them under (classname, nodeid, property)
+                try:
+                    value = str(self.get(nodeid, prop))
+                except IndexError:
+                    # node has been destroyed
+                    continue
+                self.db.indexer.add_text((self.classname, nodeid, prop), value)
+
+    #
+    # import / export support
+    #
+    def export_list(self, propnames, nodeid):
+        ''' Export a node - generate a list of CSV-able data in the order
+            specified by propnames for the given node.
+        '''
+        properties = self.getprops()
+        l = []
+        for prop in propnames:
+            proptype = properties[prop]
+            value = self.get(nodeid, prop)
+            # "marshal" data where needed
+            if value is None:
+                pass
+            elif isinstance(proptype, hyperdb.Date):
+                value = value.get_tuple()
+            elif isinstance(proptype, hyperdb.Interval):
+                value = value.get_tuple()
+            elif isinstance(proptype, hyperdb.Password):
+                value = str(value)
+            l.append(repr(value))
+
+        # append retired flag
+        l.append(repr(self.is_retired(nodeid)))
+
+        return l
+
+    def import_list(self, propnames, proplist):
+        ''' Import a node - all information including "id" is present and
+            should not be sanity checked. Triggers are not triggered. The
+            journal should be initialised using the "creator" and "created"
+            information.
+
+            Return the nodeid of the node imported.
+        '''
+        if self.db.journaltag is None:
+            raise hyperdb.DatabaseError, 'Database open read-only'
+        properties = self.getprops()
+
+        # make the new node's property map
+        d = {}
+        newid = None
+        for i in range(len(propnames)):
+            # Figure the property for this column
+            propname = propnames[i]
+
+            # Use eval to reverse the repr() used to output the CSV
+            value = eval(proplist[i])
+
+            # "unmarshal" where necessary
+            if propname == 'id':
+                newid = value
+                continue
+            elif propname == 'is retired':
+                # is the item retired?
+                if int(value):
+                    d[self.db.RETIRED_FLAG] = 1
+                continue
+            elif value is None:
+                d[propname] = None
+                continue
+
+            prop = properties[propname]
+            if isinstance(prop, hyperdb.Date):
+                value = date.Date(value)
+            elif isinstance(prop, hyperdb.Interval):
+                value = date.Interval(value)
+            elif isinstance(prop, hyperdb.Password):
+                pwd = password.Password()
+                pwd.unpack(value)
+                value = pwd
+            d[propname] = value
+
+        # get a new id if necessary
+        if newid is None:
+            newid = self.db.newid(self.classname)
+
+        # add the node and journal
+        self.db.addnode(self.classname, newid, d)
+        return newid
+
+    def export_journals(self):
+        '''Export a class's journal - generate a list of lists of
+        CSV-able data:
+
+            nodeid, date, user, action, params
+
+        No heading here - the columns are fixed.
+        '''
+        properties = self.getprops()
+        r = []
+        for nodeid in self.getnodeids():
+            for nodeid, date, user, action, params in self.history(nodeid):
+                date = date.get_tuple()
+                if action == 'set':
+                    export_data = {}
+                    for propname, value in params.items():
+                        if not properties.has_key(propname):
+                            # property no longer in the schema
+                            continue
+
+                        prop = properties[propname]
+                        # make sure the params are eval()'able
+                        if value is None:
+                            # don't export empties
+                            continue
+                        elif isinstance(prop, hyperdb.Date):
+                            # this is a hack - some dates are stored as strings
+                            if not isinstance(value, type('')):
+                                value = value.get_tuple()
+                        elif isinstance(prop, hyperdb.Interval):
+                            # hack too - some intervals are stored as strings
+                            if not isinstance(value, type('')):
+                                value = value.get_tuple()
+                        elif isinstance(prop, hyperdb.Password):
+                            value = str(value)
+                        export_data[propname] = value
+                    params = export_data
+                l = [nodeid, date, user, action, params]
+                r.append(map(repr, l))
+        return r
+
+    def import_journals(self, entries):
+        '''Import a class's journal.
+
+        Uses setjournal() to set the journal for each item.'''
+        properties = self.getprops()
+        d = {}
+        for l in entries:
+            l = map(eval, l)
+            nodeid, jdate, user, action, params = l
+            r = d.setdefault(nodeid, [])
+            if action == 'set':
+                for propname, value in params.items():
+                    prop = properties[propname]
+                    if value is None:
+                        pass
+                    elif isinstance(prop, hyperdb.Date):
+                        if type(value) == type(()):
+                            print _('WARNING: invalid date tuple %r')%(value,)
+                            value = date.Date( "2000-1-1" )
+                        value = date.Date(value)
+                    elif isinstance(prop, hyperdb.Interval):
+                        value = date.Interval(value)
+                    elif isinstance(prop, hyperdb.Password):
+                        pwd = password.Password()
+                        pwd.unpack(value)
+                        value = pwd
+                    params[propname] = value
+            r.append((nodeid, date.Date(jdate), user, action, params))
+
+        for nodeid, l in d.items():
+            self.db.setjournal(self.classname, nodeid, l)
+
+class FileClass(hyperdb.FileClass, Class):
+    '''This class defines a large chunk of data. To support this, it has a
+       mandatory String property "content" which is typically saved off
+       externally to the hyperdb.
+
+       The default MIME type of this data is defined by the
+       "default_mime_type" class attribute, which may be overridden by each
+       node if the class defines a "type" String property.
+    '''
+    def __init__(self, db, classname, **properties):
+        '''The newly-created class automatically includes the "content"
+        and "type" properties.
+        '''
+        if not properties.has_key('content'):
+            properties['content'] = hyperdb.String(indexme='yes')
+        if not properties.has_key('type'):
+            properties['type'] = hyperdb.String()
+        Class.__init__(self, db, classname, **properties)
+
+    def create(self, **propvalues):
+        ''' Snarf the "content" propvalue and store in a file
+        '''
+        # we need to fire the auditors now, or the content property won't
+        # be in propvalues for the auditors to play with
+        self.fireAuditors('create', None, propvalues)
+
+        # now remove the content property so it's not stored in the db
+        content = propvalues['content']
+        del propvalues['content']
+
+        # make sure we have a MIME type
+        mime_type = propvalues.get('type', self.default_mime_type)
+
+        # do the database create
+        newid = self.create_inner(**propvalues)
+
+        # fire reactors
+        self.fireReactors('create', newid, None)
+
+        # store off the content as a file
+        self.db.storefile(self.classname, newid, None, content)
+        return newid
+
+    def get(self, nodeid, propname, default=_marker, cache=1):
+        ''' Trap the content propname and get it from the file
+
+        'cache' exists for backwards compatibility, and is not used.
+        '''
+        poss_msg = 'Possibly an access right configuration problem.'
+        if propname == 'content':
+            try:
+                return self.db.getfile(self.classname, nodeid, None)
+            except IOError, (strerror):
+                # XXX by catching this we don't see an error in the log.
+                return 'ERROR reading file: %s%s\n%s\n%s'%(
+                        self.classname, nodeid, poss_msg, strerror)
+        if default is not _marker:
+            return Class.get(self, nodeid, propname, default)
+        else:
+            return Class.get(self, nodeid, propname)
+
+    def set(self, itemid, **propvalues):
+        ''' Snarf the "content" propvalue and update it in a file
+        '''
+        self.fireAuditors('set', itemid, propvalues)
+
+        # create the oldvalues dict - fill in any missing values
+        oldvalues = copy.deepcopy(self.db.getnode(self.classname, itemid))
+        for name,prop in self.getprops(protected=0).items():
+            if oldvalues.has_key(name):
+                continue
+            if isinstance(prop, hyperdb.Multilink):
+                oldvalues[name] = []
+            else:
+                oldvalues[name] = None
+
+        # now remove the content property so it's not stored in the db
+        content = None
+        if propvalues.has_key('content'):
+            content = propvalues['content']
+            del propvalues['content']
+
+        # do the database update
+        propvalues = self.set_inner(itemid, **propvalues)
+
+        # do content?
+        if content:
+            # store and possibly index
+            self.db.storefile(self.classname, itemid, None, content)
+            if self.properties['content'].indexme:
+                mime_type = self.get(itemid, 'type', self.default_mime_type)
+                self.db.indexer.add_text((self.classname, itemid, 'content'),
+                    content, mime_type)
+            propvalues['content'] = content
+
+        # fire reactors
+        self.fireReactors('set', itemid, oldvalues)
+        return propvalues
+
+    def index(self, nodeid):
+        ''' Add (or refresh) the node to search indexes.
+
+        Use the content-type property for the content property.
+        '''
+        # find all the String properties that have indexme
+        for prop, propclass in self.getprops().items():
+            if prop == 'content' and propclass.indexme:
+                mime_type = self.get(nodeid, 'type', self.default_mime_type)
+                self.db.indexer.add_text((self.classname, nodeid, 'content'),
+                    str(self.get(nodeid, 'content')), mime_type)
+            elif isinstance(propclass, hyperdb.String) and propclass.indexme:
+                # index them under (classname, nodeid, property)
+                try:
+                    value = str(self.get(nodeid, prop))
+                except IndexError:
+                    # node has been destroyed
+                    continue
+                self.db.indexer.add_text((self.classname, nodeid, prop), value)
+
+# deviation from spec - was called ItemClass
+class IssueClass(Class, roundupdb.IssueClass):
+    # Overridden methods:
+    def __init__(self, db, classname, **properties):
+        '''The newly-created class automatically includes the "messages",
+        "files", "nosy", and "superseder" properties.  If the 'properties'
+        dictionary attempts to specify any of these properties or a
+        "creation" or "activity" property, a ValueError is raised.
+        '''
+        if not properties.has_key('title'):
+            properties['title'] = hyperdb.String(indexme='yes')
+        if not properties.has_key('messages'):
+            properties['messages'] = hyperdb.Multilink("msg")
+        if not properties.has_key('files'):
+            properties['files'] = hyperdb.Multilink("file")
+        if not properties.has_key('nosy'):
+            # note: journalling is turned off as it really just wastes
+            # space. this behaviour may be overridden in an instance
+            properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
+        if not properties.has_key('superseder'):
+            properties['superseder'] = hyperdb.Multilink(classname)
+        Class.__init__(self, db, classname, **properties)
+
+# vim: set et sts=4 sw=4 :

Added: tracker/vendor/roundup/current/roundup/backends/back_metakit.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/backends/back_metakit.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,2104 @@
+# $Id: back_metakit.py,v 1.108 2006/04/27 04:59:37 richard Exp $
+'''Metakit backend for Roundup, originally by Gordon McMillan.
+
+Known Current Bugs:
+
+- You can't change a class' key properly. This shouldn't be too hard to fix.
+- Some unit tests are overridden.
+
+Notes by Richard:
+
+This backend has some behaviour specific to metakit:
+
+- there's no concept of an explicit "unset" in metakit, so all types
+  have some "unset" value:
+
+  ========= ===== ======================================================
+  Type      Value Action when fetching from mk
+  ========= ===== ======================================================
+  Strings   ''    convert to None
+  Date      0     (seconds since 1970-01-01.00:00:00) convert to None
+  Interval  ''    convert to None
+  Number    0     ambiguious :( - do nothing (see BACKWARDS_COMPATIBLE)
+  Boolean   0     ambiguious :( - do nothing (see BACKWARDS_COMPATABILE)
+  Link      0     convert to None
+  Multilink []    actually, mk can handle this one ;)
+  Password  ''    convert to None
+  ========= ===== ======================================================
+
+  The get/set routines handle these values accordingly by converting
+  to/from None where they can. The Number/Boolean types are not able
+  to handle an "unset" at all, so they default the "unset" to 0.
+- Metakit relies in reference counting to close the database, there is
+  no explicit close call.  This can cause issues if a metakit
+  database is referenced multiple times, one might not actually be
+  closing the db.
+- probably a bunch of stuff that I'm not aware of yet because I haven't
+  fully read through the source. One of these days....
+'''
+__docformat__ = 'restructuredtext'
+# Enable this flag to break backwards compatibility (i.e. can't read old
+# databases) but comply with more roundup features, like adding NULL support.
+BACKWARDS_COMPATIBLE = 1
+
+from roundup import hyperdb, date, password, roundupdb, security
+import logging
+import metakit
+from sessions_dbm import Sessions, OneTimeKeys
+import re, marshal, os, sys, time, calendar, shutil
+from indexer_common import Indexer
+import locking
+from roundup.date import Range
+from blobfiles import files_in_dir
+
+# view modes for opening
+# XXX FIXME BPK -> these don't do anything, they are ignored
+#  should we just get rid of them for simplicities sake?
+READ = 0
+READWRITE = 1
+
+def db_exists(config):
+    return os.path.exists(os.path.join(config.TRACKER_HOME, 'db',
+        'tracker.mk4'))
+
+def db_nuke(config):
+    shutil.rmtree(os.path.join(config.TRACKER_HOME, 'db'))
+
+# general metakit error
+class MKBackendError(Exception):
+    pass
+
+_dbs = {}
+
+def Database(config, journaltag=None):
+    ''' Only have a single instance of the Database class for each instance
+    '''
+    db = _dbs.get(config.DATABASE, None)
+    if db is None or db._db is None:
+        db = _Database(config, journaltag)
+        _dbs[config.DATABASE] = db
+    else:
+        db.journaltag = journaltag
+    return db
+
+class _Database(hyperdb.Database, roundupdb.Database):
+    # Metakit has no concept of an explicit NULL
+    BACKEND_MISSING_STRING = ''
+    BACKEND_MISSING_NUMBER = 0
+    BACKEND_MISSING_BOOLEAN = 0
+
+    def __init__(self, config, journaltag=None):
+        self.config = config
+        self.journaltag = journaltag
+        self.classes = {}
+        self.dirty = 0
+        self.lockfile = None
+        self._db = self.__open()
+        self.indexer = Indexer(self)
+        self.security = security.Security(self)
+
+        self.stats = {'cache_hits': 0, 'cache_misses': 0, 'get_items': 0,
+            'filtering': 0}
+
+        os.umask(config.UMASK)
+
+    def post_init(self):
+        if self.indexer.should_reindex():
+            self.reindex()
+
+    def refresh_database(self):
+        # XXX handle refresh
+        self.reindex()
+
+    def reindex(self, classname=None):
+        if classname:
+            classes = [self.getclass(classname)]
+        else:
+            classes = self.classes.values()
+        for klass in classes:
+            for nodeid in klass.list():
+                klass.index(nodeid)
+        self.indexer.save_index()
+
+    def getSessionManager(self):
+        return Sessions(self)
+
+    def getOTKManager(self):
+        return OneTimeKeys(self)
+
+    # --- defined in ping's spec
+    def __getattr__(self, classname):
+        if classname == 'transactions':
+            return self.dirty
+        # fall back on the classes
+        try:
+            return self.getclass(classname)
+        except KeyError, msg:
+            # KeyError's not appropriate here
+            raise AttributeError, str(msg)
+    def getclass(self, classname):
+        try:
+            return self.classes[classname]
+        except KeyError:
+            raise KeyError, 'There is no class called "%s"'%classname
+    def getclasses(self):
+        return self.classes.keys()
+    # --- end of ping's spec
+
+    # --- exposed methods
+    def commit(self):
+        '''commit all changes to the database'''
+        if self.dirty:
+            self._db.commit()
+            for cl in self.classes.values():
+                cl._commit()
+            self.indexer.save_index()
+        self.dirty = 0
+    def rollback(self):
+        '''roll back all changes since the last commit'''
+        if self.dirty:
+            for cl in self.classes.values():
+                cl._rollback()
+            self._db.rollback()
+            self._db = None
+            self._db = metakit.storage(self.dbnm, 1)
+            self.hist = self._db.view('history')
+            self.tables = self._db.view('tables')
+            self.indexer.rollback()
+            self.indexer.datadb = self._db
+        self.dirty = 0
+    def clearCache(self):
+        '''clear the internal cache by committing all pending database changes'''
+        for cl in self.classes.values():
+            cl._commit()
+    def clear(self):
+        '''clear the internal cache but don't commit any changes'''
+        for cl in self.classes.values():
+            cl._clear()
+    def hasnode(self, classname, nodeid):
+        '''does a particular class contain a nodeid?'''
+        return self.getclass(classname).hasnode(nodeid)
+    def pack(self, pack_before):
+        ''' Delete all journal entries except "create" before 'pack_before'.
+        '''
+        mindate = int(calendar.timegm(pack_before.get_tuple()))
+        i = 0
+        while i < len(self.hist):
+            if self.hist[i].date < mindate and self.hist[i].action != _CREATE:
+                self.hist.delete(i)
+            else:
+                i = i + 1
+    def addclass(self, cl):
+        ''' Add a Class to the hyperdatabase.
+        '''
+        cn = cl.classname
+        self.classes[cn] = cl
+        if self.tables.find(name=cn) < 0:
+            self.tables.append(name=cn)
+
+        # add default Edit and View permissions
+        self.security.addPermission(name="Create", klass=cn,
+            description="User is allowed to create "+cn)
+        self.security.addPermission(name="Edit", klass=cn,
+            description="User is allowed to edit "+cn)
+        self.security.addPermission(name="View", klass=cn,
+            description="User is allowed to access "+cn)
+
+    def addjournal(self, tablenm, nodeid, action, params, creator=None,
+                   creation=None):
+        ''' Journal the Action
+        'action' may be:
+
+            'create' or 'set' -- 'params' is a dictionary of property values
+            'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
+            'retire' -- 'params' is None
+        '''
+        tblid = self.tables.find(name=tablenm)
+        if tblid == -1:
+            tblid = self.tables.append(name=tablenm)
+        if creator is None:
+            creator = int(self.getuid())
+        else:
+            try:
+                creator = int(creator)
+            except TypeError:
+                creator = int(self.getclass('user').lookup(creator))
+        if creation is None:
+            creation = int(time.time())
+        elif isinstance(creation, date.Date):
+            creation = int(calendar.timegm(creation.get_tuple()))
+        # tableid:I,nodeid:I,date:I,user:I,action:I,params:B
+        self.hist.append(tableid=tblid,
+                         nodeid=int(nodeid),
+                         date=creation,
+                         action=action,
+                         user=creator,
+                         params=marshal.dumps(params))
+
+    def setjournal(self, tablenm, nodeid, journal):
+        '''Set the journal to the "journal" list.'''
+        tblid = self.tables.find(name=tablenm)
+        if tblid == -1:
+            tblid = self.tables.append(name=tablenm)
+        for nodeid, date, user, action, params in journal:
+            # tableid:I,nodeid:I,date:I,user:I,action:I,params:B
+            self.hist.append(tableid=tblid,
+                             nodeid=int(nodeid),
+                             date=date,
+                             action=action,
+                             user=int(user),
+                             params=marshal.dumps(params))
+
+    def getjournal(self, tablenm, nodeid):
+        ''' get the journal for id
+        '''
+        rslt = []
+        tblid = self.tables.find(name=tablenm)
+        if tblid == -1:
+            return rslt
+        q = self.hist.select(tableid=tblid, nodeid=int(nodeid))
+        if len(q) == 0:
+            raise IndexError, "no history for id %s in %s" % (nodeid, tablenm)
+        i = 0
+        #userclass = self.getclass('user')
+        for row in q:
+            try:
+                params = marshal.loads(row.params)
+            except ValueError:
+                logging.getLogger("hyperdb").error(
+                    "history couldn't unmarshal %r" % row.params)
+                params = {}
+            #usernm = userclass.get(str(row.user), 'username')
+            dt = date.Date(time.gmtime(row.date))
+            #rslt.append((nodeid, dt, usernm, _actionnames[row.action], params))
+            rslt.append((nodeid, dt, str(row.user), _actionnames[row.action],
+                params))
+        return rslt
+
+    def destroyjournal(self, tablenm, nodeid):
+        nodeid = int(nodeid)
+        tblid = self.tables.find(name=tablenm)
+        if tblid == -1:
+            return
+        i = 0
+        hist = self.hist
+        while i < len(hist):
+            if hist[i].tableid == tblid and hist[i].nodeid == nodeid:
+                hist.delete(i)
+            else:
+                i = i + 1
+        self.dirty = 1
+
+    def close(self):
+        ''' Close off the connection.
+        '''
+        # de-reference count the metakit databases,
+        #  as this is the only way they will be closed
+        for cl in self.classes.values():
+            cl.db = None
+        self._db = None
+        if self.lockfile is not None:
+            locking.release_lock(self.lockfile)
+        if _dbs.has_key(self.config.DATABASE):
+            del _dbs[self.config.DATABASE]
+        if self.lockfile is not None:
+            self.lockfile.close()
+            self.lockfile = None
+        self.classes = {}
+
+        # force the indexer to close
+        self.indexer.close()
+        self.indexer = None
+
+    # --- internal
+    def __open(self):
+        ''' Open the metakit database
+        '''
+        # make the database dir if it doesn't exist
+        if not os.path.exists(self.config.DATABASE):
+            os.makedirs(self.config.DATABASE)
+
+        # figure the file names
+        self.dbnm = db = os.path.join(self.config.DATABASE, 'tracker.mk4')
+        lockfilenm = db[:-3]+'lck'
+
+        # get the database lock
+        self.lockfile = locking.acquire_lock(lockfilenm)
+        self.lockfile.write(str(os.getpid()))
+        self.lockfile.flush()
+
+        # see if the schema has changed since last db access
+        self.fastopen = 0
+        if os.path.exists(db):
+            dbtm = os.path.getmtime(db)
+            schemafile = os.path.join(self.config['HOME'], 'schema.py')
+            if not os.path.isfile(schemafile):
+                # try old-style schema
+                schemafile = os.path.join(self.config['HOME'], 'dbinit.py')
+            if os.path.isfile(schemafile) \
+            and (os.path.getmtime(schemafile) < dbtm):
+                # found schema file - it's older than the db
+                self.fastopen = 1
+
+        # open the db
+        db = metakit.storage(db, 1)
+        hist = db.view('history')
+        tables = db.view('tables')
+        if not self.fastopen:
+            # create the database if it's brand new
+            if not hist.structure():
+                hist = db.getas('history[tableid:I,nodeid:I,date:I,user:I,action:I,params:B]')
+            if not tables.structure():
+                tables = db.getas('tables[name:S]')
+            db.commit()
+
+        # we now have an open, initialised database
+        self.tables = tables
+        self.hist = hist
+        return db
+
+    def setid(self, classname, maxid):
+        ''' No-op in metakit
+        '''
+        cls = self.getclass(classname)
+        cls.setid(int(maxid))
+
+    def numfiles(self):
+        '''Get number of files in storage, even across subdirectories.
+        '''
+        files_dir = os.path.join(self.config.DATABASE, 'files')
+        return files_in_dir(files_dir)
+
+_STRINGTYPE = type('')
+_LISTTYPE = type([])
+_CREATE, _SET, _RETIRE, _LINK, _UNLINK, _RESTORE = range(6)
+
+_actionnames = {
+    _CREATE : 'create',
+    _SET : 'set',
+    _RETIRE : 'retire',
+    _RESTORE : 'restore',
+    _LINK : 'link',
+    _UNLINK : 'unlink',
+}
+
+_names_to_actionnames = {
+    'create': _CREATE,
+    'set': _SET,
+    'retire': _RETIRE,
+    'restore': _RESTORE,
+    'link': _LINK,
+    'unlink': _UNLINK,
+}
+
+_marker = []
+
+_ALLOWSETTINGPRIVATEPROPS = 0
+
+class Class(hyperdb.Class):
+    ''' The handle to a particular class of nodes in a hyperdatabase.
+
+        All methods except __repr__ and getnode must be implemented by a
+        concrete backend Class of which this is one.
+    '''
+
+    privateprops = None
+    def __init__(self, db, classname, **properties):
+        if hasattr(db, classname):
+            raise ValueError, "Class %s already exists"%classname
+
+        hyperdb.Class.__init__ (self, db, classname, **properties)
+        self.db = db # why isn't this a weakref as for other backends??
+        self.key = None
+        self.ruprops = self.properties
+        self.privateprops = { 'id' : hyperdb.String(),
+                              'activity' : hyperdb.Date(),
+                              'actor' : hyperdb.Link('user'),
+                              'creation' : hyperdb.Date(),
+                              'creator'  : hyperdb.Link('user') }
+
+        self.idcache = {}
+        self.uncommitted = {}
+        self.comactions = []
+        self.rbactions = []
+
+        view = self.__getview()
+        self.maxid = 1
+        if view:
+            self.maxid = view[-1].id + 1
+
+    def setid(self, maxid):
+        self.maxid = maxid + 1
+
+    def enableJournalling(self):
+        '''Turn journalling on for this class
+        '''
+        self.do_journal = 1
+
+    def disableJournalling(self):
+        '''Turn journalling off for this class
+        '''
+        self.do_journal = 0
+
+    # --- the hyperdb.Class methods
+    def create(self, **propvalues):
+        ''' Create a new node of this class and return its id.
+
+        The keyword arguments in 'propvalues' map property names to values.
+
+        The values of arguments must be acceptable for the types of their
+        corresponding properties or a TypeError is raised.
+
+        If this class has a key property, it must be present and its value
+        must not collide with other key strings or a ValueError is raised.
+
+        Any other properties on this class that are missing from the
+        'propvalues' dictionary are set to None.
+
+        If an id in a link or multilink property does not refer to a valid
+        node, an IndexError is raised.
+        '''
+        if not propvalues:
+            raise ValueError, "Need something to create!"
+        self.fireAuditors('create', None, propvalues)
+        newid = self.create_inner(**propvalues)
+        self.fireReactors('create', newid, None)
+        return newid
+
+    def create_inner(self, **propvalues):
+       ''' Called by create, in-between the audit and react calls.
+       '''
+       rowdict = {}
+       rowdict['id'] = newid = self.maxid
+       self.maxid += 1
+       ndx = self.getview(READWRITE).append(rowdict)
+       propvalues['#ISNEW'] = 1
+       try:
+           self.set_inner(str(newid), **propvalues)
+       except Exception:
+           self.maxid -= 1
+           raise
+       return str(newid)
+
+    def get(self, nodeid, propname, default=_marker, cache=1):
+        '''Get the value of a property on an existing node of this class.
+
+        'nodeid' must be the id of an existing node of this class or an
+        IndexError is raised.  'propname' must be the name of a property
+        of this class or a KeyError is raised.
+
+        'cache' exists for backwards compatibility, and is not used.
+        '''
+        view = self.getview()
+        id = int(nodeid)
+        if cache == 0:
+            oldnode = self.uncommitted.get(id, None)
+            if oldnode and oldnode.has_key(propname):
+                raw = oldnode[propname]
+                converter = _converters.get(raw.__class__, None)
+                if converter:
+                    return converter(raw)
+                return raw
+        ndx = self.idcache.get(id, None)
+
+        if ndx is None:
+            ndx = view.find(id=id)
+            if ndx < 0:
+                raise IndexError, "%s has no node %s" % (self.classname, nodeid)
+            self.idcache[id] = ndx
+        try:
+            raw = getattr(view[ndx], propname)
+        except AttributeError:
+            raise KeyError, propname
+        rutyp = self.ruprops.get(propname, None)
+
+        if rutyp is None:
+            rutyp = self.privateprops[propname]
+
+        converter = _converters.get(rutyp.__class__, None)
+        if converter:
+            raw = converter(raw)
+        return raw
+
+    def set(self, nodeid, **propvalues):
+        '''Modify a property on an existing node of this class.
+
+        'nodeid' must be the id of an existing node of this class or an
+        IndexError is raised.
+
+        Each key in 'propvalues' must be the name of a property of this
+        class or a KeyError is raised.
+
+        All values in 'propvalues' must be acceptable types for their
+        corresponding properties or a TypeError is raised.
+
+        If the value of the key property is set, it must not collide with
+        other key strings or a ValueError is raised.
+
+        If the value of a Link or Multilink property contains an invalid
+        node id, a ValueError is raised.
+        '''
+        self.fireAuditors('set', nodeid, propvalues)
+        propvalues, oldnode = self.set_inner(nodeid, **propvalues)
+        self.fireReactors('set', nodeid, oldnode)
+
+    def set_inner(self, nodeid, **propvalues):
+        '''Called outside of auditors'''
+        isnew = 0
+        if propvalues.has_key('#ISNEW'):
+            isnew = 1
+            del propvalues['#ISNEW']
+
+        if propvalues.has_key('id'):
+            raise KeyError, '"id" is reserved'
+        if self.db.journaltag is None:
+            raise hyperdb.DatabaseError, 'Database open read-only'
+        view = self.getview(READWRITE)
+
+        # node must exist & not be retired
+        id = int(nodeid)
+        ndx = view.find(id=id)
+        if ndx < 0:
+            raise IndexError, "%s has no node %s" % (self.classname, nodeid)
+        row = view[ndx]
+        if row._isdel:
+            raise IndexError, "%s has no node %s" % (self.classname, nodeid)
+        oldnode = self.uncommitted.setdefault(id, {})
+        changes = {}
+
+        for key, value in propvalues.items():
+            # this will raise the KeyError if the property isn't valid
+            # ... we don't use getprops() here because we only care about
+            # the writeable properties.
+            if _ALLOWSETTINGPRIVATEPROPS:
+                prop = self.ruprops.get(key, None)
+                if not prop:
+                    prop = self.privateprops[key]
+            else:
+                prop = self.ruprops[key]
+            converter = _converters.get(prop.__class__, lambda v: v)
+            # if the value's the same as the existing value, no sense in
+            # doing anything
+            oldvalue = converter(getattr(row, key))
+            if  value == oldvalue:
+                del propvalues[key]
+                continue
+
+            # check to make sure we're not duplicating an existing key
+            if key == self.key:
+                iv = self.getindexview(READWRITE)
+                ndx = iv.find(k=value)
+                if ndx == -1:
+                    iv.append(k=value, i=row.id)
+                    if not isnew:
+                        ndx = iv.find(k=oldvalue)
+                        if ndx > -1:
+                            iv.delete(ndx)
+                else:
+                    raise ValueError, 'node with key "%s" exists'%value
+
+            # do stuff based on the prop type
+            if isinstance(prop, hyperdb.Link):
+                link_class = prop.classname
+                # must be a string or None
+                if value is not None and not isinstance(value, type('')):
+                    raise ValueError, 'property "%s" link value be a string'%(
+                        key)
+                # Roundup sets to "unselected" by passing None
+                if value is None:
+                    value = 0
+                # if it isn't a number, it's a key
+                try:
+                    int(value)
+                except ValueError:
+                    try:
+                        value = self.db.getclass(link_class).lookup(value)
+                    except (TypeError, KeyError):
+                        raise IndexError, 'new property "%s": %s not a %s'%(
+                            key, value, prop.classname)
+
+                if (value is not None and
+                        not self.db.getclass(link_class).hasnode(value)):
+                    raise IndexError, '%s has no node %s'%(link_class, value)
+
+                setattr(row, key, int(value))
+                changes[key] = oldvalue
+
+                if self.do_journal and prop.do_journal:
+                    # register the unlink with the old linked node
+                    if oldvalue:
+                        self.db.addjournal(link_class, oldvalue, _UNLINK,
+                            (self.classname, str(row.id), key))
+
+                    # register the link with the newly linked node
+                    if value:
+                        self.db.addjournal(link_class, value, _LINK,
+                            (self.classname, str(row.id), key))
+
+            elif isinstance(prop, hyperdb.Multilink):
+                if value is not None and type(value) != _LISTTYPE:
+                    raise TypeError, 'new property "%s" not a list of ids'%key
+                link_class = prop.classname
+                l = []
+                if value is None:
+                    value = []
+                for entry in value:
+                    if type(entry) != _STRINGTYPE:
+                        raise ValueError, 'new property "%s" link value ' \
+                            'must be a string'%key
+                    # if it isn't a number, it's a key
+                    try:
+                        int(entry)
+                    except ValueError:
+                        try:
+                            entry = self.db.getclass(link_class).lookup(entry)
+                        except (TypeError, KeyError):
+                            raise IndexError, 'new property "%s": %s not a %s'%(
+                                key, entry, prop.classname)
+                    l.append(entry)
+                propvalues[key] = value = l
+
+                # handle removals
+                rmvd = []
+                for id in oldvalue:
+                    if id not in value:
+                        rmvd.append(id)
+                        # register the unlink with the old linked node
+                        if self.do_journal and prop.do_journal:
+                            self.db.addjournal(link_class, id, _UNLINK,
+                                (self.classname, str(row.id), key))
+
+                # handle additions
+                adds = []
+                for id in value:
+                    if id not in oldvalue:
+                        if not self.db.getclass(link_class).hasnode(id):
+                            raise IndexError, '%s has no node %s'%(
+                                link_class, id)
+                        adds.append(id)
+                        # register the link with the newly linked node
+                        if self.do_journal and prop.do_journal:
+                            self.db.addjournal(link_class, id, _LINK,
+                                (self.classname, str(row.id), key))
+
+                # perform the modifications on the actual property value
+                sv = getattr(row, key)
+                i = 0
+                while i < len(sv):
+                    if str(sv[i].fid) in rmvd:
+                        sv.delete(i)
+                    else:
+                        i += 1
+                for id in adds:
+                    sv.append(fid=int(id))
+
+                # figure the journal entry
+                l = []
+                if adds:
+                    l.append(('+', adds))
+                if rmvd:
+                    l.append(('-', rmvd))
+                if l:
+                    changes[key] = tuple(l)
+                #changes[key] = oldvalue
+
+                if not rmvd and not adds:
+                    del propvalues[key]
+
+            elif isinstance(prop, hyperdb.String):
+                if value is not None and type(value) != _STRINGTYPE:
+                    raise TypeError, 'new property "%s" not a string'%key
+                if value is None:
+                    value = ''
+                setattr(row, key, value)
+                changes[key] = oldvalue
+                if hasattr(prop, 'isfilename') and prop.isfilename:
+                    propvalues[key] = os.path.basename(value)
+                if prop.indexme:
+                    self.db.indexer.add_text((self.classname, nodeid, key),
+                        value, 'text/plain')
+
+            elif isinstance(prop, hyperdb.Password):
+                if value is not None and not isinstance(value, password.Password):
+                    raise TypeError, 'new property "%s" not a Password'% key
+                if value is None:
+                    value = ''
+                setattr(row, key, str(value))
+                changes[key] = str(oldvalue)
+                propvalues[key] = str(value)
+
+            elif isinstance(prop, hyperdb.Date):
+                if value is not None and not isinstance(value, date.Date):
+                    raise TypeError, 'new property "%s" not a Date'% key
+                if value is None:
+                    setattr(row, key, 0)
+                else:
+                    setattr(row, key, int(calendar.timegm(value.get_tuple())))
+                if oldvalue is None:
+                    changes[key] = oldvalue
+                else:
+                    changes[key] = str(oldvalue)
+                propvalues[key] = str(value)
+
+            elif isinstance(prop, hyperdb.Interval):
+                if value is not None and not isinstance(value, date.Interval):
+                    raise TypeError, 'new property "%s" not an Interval'% key
+                if value is None:
+                    setattr(row, key, '')
+                else:
+                    # kedder: we should store interval values serialized
+                    setattr(row, key, value.serialise())
+                changes[key] = str(oldvalue)
+                propvalues[key] = str(value)
+
+            elif isinstance(prop, hyperdb.Number):
+                if value is None:
+                    v = 0
+                else:
+                    try:
+                        v = float(value)
+                    except ValueError:
+                        raise TypeError, "%s (%s) is not numeric"%(key, repr(value))
+                    if not BACKWARDS_COMPATIBLE:
+                        if v >=0:
+                            v = v + 1
+                setattr(row, key, v)
+                changes[key] = oldvalue
+                propvalues[key] = value
+
+            elif isinstance(prop, hyperdb.Boolean):
+                if value is None:
+                    bv = 0
+                elif value not in (0,1):
+                    raise TypeError, "%s (%s) is not boolean"%(key, repr(value))
+                else:
+                    bv = value
+                    if not BACKWARDS_COMPATIBLE:
+                        bv += 1
+                setattr(row, key, bv)
+                changes[key] = oldvalue
+                propvalues[key] = value
+
+            oldnode[key] = oldvalue
+
+        # nothing to do?
+        if not isnew and not propvalues:
+            return propvalues, oldnode
+        if not propvalues.has_key('activity'):
+            row.activity = int(time.time())
+        if not propvalues.has_key('actor'):
+            row.actor = int(self.db.getuid())
+        if isnew:
+            if not row.creation:
+                row.creation = int(time.time())
+            if not row.creator:
+                row.creator = int(self.db.getuid())
+
+        self.db.dirty = 1
+
+        if self.do_journal:
+            if isnew:
+                self.db.addjournal(self.classname, nodeid, _CREATE, {})
+            else:
+                self.db.addjournal(self.classname, nodeid, _SET, changes)
+
+        return propvalues, oldnode
+
+    def retire(self, nodeid):
+        '''Retire a node.
+
+        The properties on the node remain available from the get() method,
+        and the node's id is never reused.
+
+        Retired nodes are not returned by the find(), list(), or lookup()
+        methods, and other nodes may reuse the values of their key properties.
+        '''
+        if self.db.journaltag is None:
+            raise hyperdb.DatabaseError, 'Database open read-only'
+        self.fireAuditors('retire', nodeid, None)
+        view = self.getview(READWRITE)
+        ndx = view.find(id=int(nodeid))
+        if ndx < 0:
+            raise KeyError, "nodeid %s not found" % nodeid
+
+        row = view[ndx]
+        oldvalues = self.uncommitted.setdefault(row.id, {})
+        oldval = oldvalues['_isdel'] = row._isdel
+        row._isdel = 1
+
+        if self.do_journal:
+            self.db.addjournal(self.classname, nodeid, _RETIRE, {})
+        if self.key:
+            iv = self.getindexview(READWRITE)
+            ndx = iv.find(k=getattr(row, self.key))
+            # find is broken with multiple attribute lookups
+            # on ordered views
+            #ndx = iv.find(k=getattr(row, self.key),i=row.id)
+            if ndx > -1 and iv[ndx].i == row.id:
+                iv.delete(ndx)
+
+        self.db.dirty = 1
+        self.fireReactors('retire', nodeid, None)
+
+    def restore(self, nodeid):
+        '''Restore a retired node.
+
+        Make node available for all operations like it was before retirement.
+        '''
+        if self.db.journaltag is None:
+            raise hyperdb.DatabaseError, 'Database open read-only'
+
+        # check if key property was overrided
+        key = self.getkey()
+        keyvalue = self.get(nodeid, key)
+
+        try:
+            id = self.lookup(keyvalue)
+        except KeyError:
+            pass
+        else:
+            raise KeyError, "Key property (%s) of retired node clashes with \
+                existing one (%s)" % (key, keyvalue)
+        # Now we can safely restore node
+        self.fireAuditors('restore', nodeid, None)
+        view = self.getview(READWRITE)
+        ndx = view.find(id=int(nodeid))
+        if ndx < 0:
+            raise KeyError, "nodeid %s not found" % nodeid
+
+        row = view[ndx]
+        oldvalues = self.uncommitted.setdefault(row.id, {})
+        oldval = oldvalues['_isdel'] = row._isdel
+        row._isdel = 0
+
+        if self.do_journal:
+            self.db.addjournal(self.classname, nodeid, _RESTORE, {})
+        if self.key:
+            iv = self.getindexview(READWRITE)
+            ndx = iv.find(k=getattr(row, self.key),i=row.id)
+            if ndx > -1:
+                iv.delete(ndx)
+        self.db.dirty = 1
+        self.fireReactors('restore', nodeid, None)
+
+    def is_retired(self, nodeid):
+        '''Return true if the node is retired
+        '''
+        view = self.getview(READWRITE)
+        # node must exist & not be retired
+        id = int(nodeid)
+        ndx = view.find(id=id)
+        if ndx < 0:
+            raise IndexError, "%s has no node %s" % (self.classname, nodeid)
+        row = view[ndx]
+        return row._isdel
+
+    def history(self, nodeid):
+        '''Retrieve the journal of edits on a particular node.
+
+        'nodeid' must be the id of an existing node of this class or an
+        IndexError is raised.
+
+        The returned list contains tuples of the form
+
+            (nodeid, date, tag, action, params)
+
+        'date' is a Timestamp object specifying the time of the change and
+        'tag' is the journaltag specified when the database was opened.
+        '''
+        if not self.do_journal:
+            raise ValueError, 'Journalling is disabled for this class'
+        return self.db.getjournal(self.classname, nodeid)
+
+    def setkey(self, propname):
+        '''Select a String property of this class to be the key property.
+
+        'propname' must be the name of a String property of this class or
+        None, or a TypeError is raised.  The values of the key property on
+        all existing nodes must be unique or a ValueError is raised.
+        '''
+        if self.key:
+            if propname == self.key:
+                return
+            else:
+                # drop the old key table
+                tablename = "_%s.%s"%(self.classname, self.key)
+                self.db._db.getas(tablename)
+
+            #raise ValueError, "%s already indexed on %s"%(self.classname,
+            #    self.key)
+
+        prop = self.properties.get(propname, None)
+        if prop is None:
+            prop = self.privateprops.get(propname, None)
+        if prop is None:
+            raise KeyError, "no property %s" % propname
+        if not isinstance(prop, hyperdb.String):
+            raise TypeError, "%s is not a String" % propname
+
+        # the way he index on properties is by creating a
+        # table named _%(classname)s.%(key)s, if this table
+        # exists then everything is okay.  If this table
+        # doesn't exist, then generate a new table on the
+        # key value.
+
+        # first setkey for this run or key has been changed
+        self.key = propname
+        tablename = "_%s.%s"%(self.classname, self.key)
+
+        iv = self.db._db.view(tablename)
+        if self.db.fastopen and iv.structure():
+            return
+
+        # very first setkey ever or the key has changed
+        self.db.dirty = 1
+        iv = self.db._db.getas('_%s[k:S,i:I]' % tablename)
+        iv = iv.ordered(1)
+        for row in self.getview():
+            iv.append(k=getattr(row, propname), i=row.id)
+        self.db.commit()
+
+    def getkey(self):
+       '''Return the name of the key property for this class or None.'''
+       return self.key
+
+    def lookup(self, keyvalue):
+        '''Locate a particular node by its key property and return its id.
+
+        If this class has no key property, a TypeError is raised.  If the
+        keyvalue matches one of the values for the key property among
+        the nodes in this class, the matching node's id is returned;
+        otherwise a KeyError is raised.
+        '''
+        if not self.key:
+            raise TypeError, 'No key property set for class %s'%self.classname
+
+        if type(keyvalue) is not _STRINGTYPE:
+            raise TypeError, '%r is not a string'%keyvalue
+
+        # XXX FIX ME -> this is a bit convoluted
+        # First we search the index view to get the id
+        # which is a quicker look up.
+        # Then we lookup the row with id=id
+        # if the _isdel property of the row is 0, return the
+        # string version of the id. (Why string version???)
+        #
+        # Otherwise, just lookup the non-indexed key
+        # in the non-index table and check the _isdel property
+        iv = self.getindexview()
+        if iv:
+            # look up the index view for the id,
+            # then instead of looking up the keyvalue, lookup the
+            # quicker id
+            ndx = iv.find(k=keyvalue)
+            if ndx > -1:
+                view = self.getview()
+                ndx = view.find(id=iv[ndx].i)
+                if ndx > -1:
+                    row = view[ndx]
+                    if not row._isdel:
+                        return str(row.id)
+        else:
+            # perform the slower query
+            view = self.getview()
+            ndx = view.find({self.key:keyvalue})
+            if ndx > -1:
+                row = view[ndx]
+                if not row._isdel:
+                    return str(row.id)
+
+        raise KeyError, keyvalue
+
+    def destroy(self, id):
+        '''Destroy a node.
+
+        WARNING: this method should never be used except in extremely rare
+                 situations where there could never be links to the node being
+                 deleted
+
+        WARNING: use retire() instead
+
+        WARNING: the properties of this node will not be available ever again
+
+        WARNING: really, use retire() instead
+
+        Well, I think that's enough warnings. This method exists mostly to
+        support the session storage of the cgi interface.
+
+        The node is completely removed from the hyperdb, including all journal
+        entries. It will no longer be available, and will generally break code
+        if there are any references to the node.
+        '''
+        view = self.getview(READWRITE)
+        ndx = view.find(id=int(id))
+        if ndx > -1:
+            if self.key:
+                keyvalue = getattr(view[ndx], self.key)
+                iv = self.getindexview(READWRITE)
+                if iv:
+                    ivndx = iv.find(k=keyvalue)
+                    if ivndx > -1:
+                        iv.delete(ivndx)
+            view.delete(ndx)
+            self.db.destroyjournal(self.classname, id)
+            self.db.dirty = 1
+
+    def find(self, **propspec):
+        '''Get the ids of nodes in this class which link to the given nodes.
+
+        'propspec' consists of keyword args propname=nodeid or
+                   propname={nodeid:1, }
+        'propname' must be the name of a property in this class, or a
+                   KeyError is raised.  That property must be a Link or
+                   Multilink property, or a TypeError is raised.
+
+        Any node in this class whose 'propname' property links to any of
+        the nodeids will be returned. Examples::
+
+            db.issue.find(messages='1')
+            db.issue.find(messages={'1':1,'3':1}, files={'7':1})
+        '''
+        propspec = propspec.items()
+        for propname, nodeid in propspec:
+            # check the prop is OK
+            prop = self.ruprops[propname]
+            if (not isinstance(prop, hyperdb.Link) and
+                    not isinstance(prop, hyperdb.Multilink)):
+                raise TypeError, "'%s' not a Link/Multilink property"%propname
+
+        vws = []
+        for propname, ids in propspec:
+            if type(ids) is _STRINGTYPE:
+                ids = {int(ids):1}
+            elif ids is None:
+                ids = {0:1}
+            else:
+                d = {}
+                for id in ids.keys():
+                    if id is None:
+                        d[0] = 1
+                    else:
+                        d[int(id)] = 1
+                ids = d
+            prop = self.ruprops[propname]
+            view = self.getview()
+            if isinstance(prop, hyperdb.Multilink):
+                def ff(row, nm=propname, ids=ids):
+                    if not row._isdel:
+                        sv = getattr(row, nm)
+                        for sr in sv:
+                            if ids.has_key(sr.fid):
+                                return 1
+                    return 0
+            else:
+                def ff(row, nm=propname, ids=ids):
+                    return not row._isdel and ids.has_key(getattr(row, nm))
+            ndxview = view.filter(ff)
+            vws.append(ndxview.unique())
+
+        # handle the empty match case
+        if not vws:
+            return []
+
+        ndxview = vws[0]
+        for v in vws[1:]:
+            ndxview = ndxview.union(v)
+        view = self.getview().remapwith(ndxview)
+        rslt = []
+        for row in view:
+            rslt.append(str(row.id))
+        return rslt
+
+
+    def list(self):
+        ''' Return a list of the ids of the active nodes in this class.
+        '''
+        l = []
+        for row in self.getview().select(_isdel=0):
+            l.append(str(row.id))
+        return l
+
+    def getnodeids(self, retired=None):
+        ''' Retrieve all the ids of the nodes for a particular Class.
+
+            Set retired=None to get all nodes. Otherwise it'll get all the
+            retired or non-retired nodes, depending on the flag.
+        '''
+        l = []
+        if retired is False or retired is True:
+            result = self.getview().select(_isdel=retired)
+        else:
+            result = self.getview()
+        for row in result:
+            l.append(str(row.id))
+        return l
+
+    def count(self):
+        return len(self.getview())
+
+    def getprops(self, protected=1):
+        # protected is not in ping's spec
+        allprops = self.ruprops.copy()
+        if protected and self.privateprops is not None:
+            allprops.update(self.privateprops)
+        return allprops
+
+    def addprop(self, **properties):
+        for key in properties.keys():
+            if self.ruprops.has_key(key):
+                raise ValueError, "%s is already a property of %s"%(key,
+                    self.classname)
+        self.ruprops.update(properties)
+        # Class structure has changed
+        self.db.fastopen = 0
+        view = self.__getview()
+        self.db.commit()
+    # ---- end of ping's spec
+
+    def filter(self, search_matches, filterspec, sort=(None,None),
+            group=(None,None)):
+        '''Return a list of the ids of the active nodes in this class that
+        match the 'filter' spec, sorted by the group spec and then the
+        sort spec
+
+        "filterspec" is {propname: value(s)}
+
+        "sort" and "group" are (dir, prop) where dir is '+', '-' or None
+        and prop is a prop name or None
+
+        "search_matches" is {nodeid: marker} or None
+
+        The filter must match all properties specificed - but if the
+        property value to match is a list, any one of the values in the
+        list may match for that property to match.
+        '''
+        if __debug__:
+            start_t = time.time()
+
+        where = {'_isdel':0}
+        wherehigh = {}
+        mlcriteria = {}
+        regexes = []
+        orcriteria = {}
+        for propname, value in filterspec.items():
+            prop = self.ruprops.get(propname, None)
+            if prop is None:
+                prop = self.privateprops[propname]
+            if isinstance(prop, hyperdb.Multilink):
+                if value in ('-1', ['-1']):
+                    value = []
+                elif type(value) is not _LISTTYPE:
+                    value = [value]
+                # transform keys to ids
+                u = []
+                for item in value:
+                    try:
+                        item = int(item)
+                    except (TypeError, ValueError):
+                        item = int(self.db.getclass(prop.classname).lookup(item))
+                    if item == -1:
+                        item = 0
+                    u.append(item)
+                mlcriteria[propname] = u
+            elif isinstance(prop, hyperdb.Link):
+                if type(value) is not _LISTTYPE:
+                    value = [value]
+                # transform keys to ids
+                u = []
+                for item in value:
+                    if item is None:
+                        item = -1
+                    else:
+                        try:
+                            item = int(item)
+                        except (TypeError, ValueError):
+                            linkcl = self.db.getclass(prop.classname)
+                            item = int(linkcl.lookup(item))
+                    if item == -1:
+                        item = 0
+                    u.append(item)
+                if len(u) == 1:
+                    where[propname] = u[0]
+                else:
+                    orcriteria[propname] = u
+            elif isinstance(prop, hyperdb.String):
+                if type(value) is not type([]):
+                    value = [value]
+                for v in value:
+                    # simple glob searching
+                    v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
+                    v = v.replace('?', '.')
+                    v = v.replace('*', '.*?')
+                    regexes.append((propname, re.compile(v, re.I)))
+            elif propname == 'id':
+                where[propname] = int(value)
+            elif isinstance(prop, hyperdb.Boolean):
+                if type(value) is _STRINGTYPE:
+                    bv = value.lower() in ('yes', 'true', 'on', '1')
+                else:
+                    bv = value
+                where[propname] = bv
+            elif isinstance(prop, hyperdb.Date):
+                try:
+                    # Try to filter on range of dates
+                    date_rng = prop.range_from_raw (value, self.db)
+                    if date_rng.from_value:
+                        t = date_rng.from_value.get_tuple()
+                        where[propname] = int(calendar.timegm(t))
+                    else:
+                        # use minimum possible value to exclude items without
+                        # 'prop' property
+                        where[propname] = 0
+                    if date_rng.to_value:
+                        t = date_rng.to_value.get_tuple()
+                        wherehigh[propname] = int(calendar.timegm(t))
+                    else:
+                        wherehigh[propname] = None
+                except ValueError:
+                    # If range creation fails - ignore that search parameter
+                    pass
+            elif isinstance(prop, hyperdb.Interval):
+                try:
+                    # Try to filter on range of intervals
+                    date_rng = Range(value, date.Interval)
+                    if date_rng.from_value:
+                        #t = date_rng.from_value.get_tuple()
+                        where[propname] = date_rng.from_value.serialise()
+                    else:
+                        # use minimum possible value to exclude items without
+                        # 'prop' property
+                        where[propname] = '-99999999999999'
+                    if date_rng.to_value:
+                        #t = date_rng.to_value.get_tuple()
+                        wherehigh[propname] = date_rng.to_value.serialise()
+                    else:
+                        wherehigh[propname] = None
+                except ValueError:
+                    # If range creation fails - ignore that search parameter
+                    pass
+            elif isinstance(prop, hyperdb.Number):
+                if type(value) is _LISTTYPE:
+                    orcriteria[propname] = [float(v) for v in value]
+                else:
+                    where[propname] = float(value)
+            else:
+                where[propname] = str(value)
+        v = self.getview()
+        if where:
+            where_higherbound = where.copy()
+            where_higherbound.update(wherehigh)
+            v = v.select(where, where_higherbound)
+
+        if mlcriteria:
+            # multilink - if any of the nodeids required by the
+            # filterspec aren't in this node's property, then skip it
+            def ff(row, ml=mlcriteria):
+                for propname, values in ml.items():
+                    sv = getattr(row, propname)
+                    if not values and not sv:
+                        return 1
+                    for id in values:
+                        if sv.find(fid=id) != -1:
+                            return 1
+                return 0
+            iv = v.filter(ff)
+            v = v.remapwith(iv)
+
+        if orcriteria:
+            def ff(row, crit=orcriteria):
+                for propname, allowed in crit.items():
+                    val = getattr(row, propname)
+                    if val not in allowed:
+                        return 0
+                return 1
+
+            iv = v.filter(ff)
+            v = v.remapwith(iv)
+
+        if regexes:
+            def ff(row, r=regexes):
+                for propname, regex in r:
+                    val = str(getattr(row, propname))
+                    if not regex.search(val):
+                        return 0
+                return 1
+
+            iv = v.filter(ff)
+            v = v.remapwith(iv)
+
+        if sort or group:
+            sortspec = []
+            rev = []
+            for dir, propname in group, sort:
+                if propname is None: continue
+                isreversed = 0
+                if dir == '-':
+                    isreversed = 1
+                try:
+                    prop = getattr(v, propname)
+                except AttributeError:
+                    logging.getLogger("hyperdb").error(
+                        "MK has no property %s" % propname)
+                    continue
+                propclass = self.ruprops.get(propname, None)
+                if propclass is None:
+                    propclass = self.privateprops.get(propname, None)
+                    if propclass is None:
+                        logging.getLogger("hyperdb").error(
+                            "Schema has no property %s" % propname)
+                        continue
+                if isinstance(propclass, hyperdb.Link):
+                    linkclass = self.db.getclass(propclass.classname)
+                    lv = linkclass.getview()
+                    lv = lv.rename('id', propname)
+                    v = v.join(lv, prop, 1)
+                    prop = getattr(v, linkclass.orderprop())
+                if isreversed:
+                    rev.append(prop)
+                sortspec.append(prop)
+            v = v.sortrev(sortspec, rev)[:] #XXX Metakit bug
+
+        rslt = []
+        for row in v:
+            id = str(row.id)
+            if search_matches is not None:
+                if search_matches.has_key(id):
+                    rslt.append(id)
+            else:
+                rslt.append(id)
+
+        if __debug__:
+            self.db.stats['filtering'] += (time.time() - start_t)
+
+        return rslt
+
+    def hasnode(self, nodeid):
+        '''Determine if the given nodeid actually exists
+        '''
+        return int(nodeid) < self.maxid
+
+    def stringFind(self, **requirements):
+        '''Locate a particular node by matching a set of its String
+        properties in a caseless search.
+
+        If the property is not a String property, a TypeError is raised.
+
+        The return is a list of the id of all nodes that match.
+        '''
+        for propname in requirements.keys():
+            prop = self.properties[propname]
+            if isinstance(not prop, hyperdb.String):
+                raise TypeError, "'%s' not a String property"%propname
+            requirements[propname] = requirements[propname].lower()
+        requirements['_isdel'] = 0
+
+        l = []
+        for row in self.getview().select(requirements):
+            l.append(str(row.id))
+        return l
+
+    def addjournal(self, nodeid, action, params):
+        '''Add a journal to the given nodeid,
+        'action' may be:
+
+            'create' or 'set' -- 'params' is a dictionary of property values
+            'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
+            'retire' -- 'params' is None
+        '''
+        self.db.addjournal(self.classname, nodeid, action, params)
+
+    def index(self, nodeid):
+        ''' Add (or refresh) the node to search indexes '''
+        # find all the String properties that have indexme
+        for prop, propclass in self.getprops().items():
+            if isinstance(propclass, hyperdb.String) and propclass.indexme:
+                # index them under (classname, nodeid, property)
+                self.db.indexer.add_text((self.classname, nodeid, prop),
+                                str(self.get(nodeid, prop)))
+
+    # --- used by Database
+    def _commit(self):
+        ''' called post commit of the DB.
+            interested subclasses may override '''
+        self.uncommitted = {}
+        for action in self.comactions:
+            action()
+        self.comactions = []
+        self.rbactions = []
+        self.idcache = {}
+    def _rollback(self):
+        ''' called pre rollback of the DB.
+            interested subclasses may override '''
+        self.comactions = []
+        for action in self.rbactions:
+            action()
+        self.rbactions = []
+        self.uncommitted = {}
+        self.idcache = {}
+    def _clear(self):
+        view = self.getview(READWRITE)
+        if len(view):
+            view[:] = []
+            self.db.dirty = 1
+        iv = self.getindexview(READWRITE)
+        if iv:
+            iv[:] = []
+    def commitaction(self, action):
+        ''' call this to register a callback called on commit
+            callback is removed on end of transaction '''
+        self.comactions.append(action)
+    def rollbackaction(self, action):
+        ''' call this to register a callback called on rollback
+            callback is removed on end of transaction '''
+        self.rbactions.append(action)
+    # --- internal
+    def __getview(self):
+        ''' Find the interface for a specific Class in the hyperdb.
+
+            This method checks to see whether the schema has changed and
+            re-works the underlying metakit structure if it has.
+        '''
+        db = self.db._db
+        view = db.view(self.classname)
+        mkprops = view.structure()
+
+        # if we have structure in the database, and the structure hasn't
+        # changed
+        # note on view.ordered ->
+        # return a metakit view ordered on the id column
+        # id is always the first column.  This speeds up
+        # look-ups on the id column.
+
+        if mkprops and self.db.fastopen:
+            return view.ordered(1)
+
+        # is the definition the same?
+        for nm, rutyp in self.ruprops.items():
+            for mkprop in mkprops:
+                if mkprop.name == nm:
+                    break
+            else:
+                mkprop = None
+            if mkprop is None:
+                break
+            if _typmap[rutyp.__class__] != mkprop.type:
+                break
+        else:
+            # make sure we have the 'actor' property too
+            for mkprop in mkprops:
+                if mkprop.name == 'actor':
+                    return view.ordered(1)
+
+        # The schema has changed.  We need to create or restructure the mk view
+        # id comes first, so we can use view.ordered(1) so that
+        # MK will order it for us to allow binary-search quick lookups on
+        # the id column
+        self.db.dirty = 1
+        s = ["%s[id:I" % self.classname]
+
+        # these columns will always be added, we can't trample them :)
+        _columns = {"id":"I", "_isdel":"I", "activity":"I", "actor": "I",
+            "creation":"I", "creator":"I"}
+
+        for nm, rutyp in self.ruprops.items():
+            mktyp = _typmap[rutyp.__class__].upper()
+            if nm in _columns and _columns[nm] != mktyp:
+                # oops, two columns with the same name and different properties
+               raise MKBackendError("column %s for table %sis defined with multiple types"%(nm, self.classname))
+            _columns[nm] = mktyp
+            s.append('%s:%s' % (nm, mktyp))
+            if mktyp == 'V':
+                s[-1] += ('[fid:I]')
+
+        # XXX FIX ME -> in some tests, creation:I becomes creation:S is this
+        # okay?  Does this need to be supported?
+        s.append('_isdel:I,activity:I,actor:I,creation:I,creator:I]')
+        view = self.db._db.getas(','.join(s))
+        self.db.commit()
+        return view.ordered(1)
+    def getview(self, RW=0):
+        # XXX FIX ME -> The RW flag doesn't do anything.
+        return self.db._db.view(self.classname).ordered(1)
+    def getindexview(self, RW=0):
+        # XXX FIX ME -> The RW flag doesn't do anything.
+        tablename = "_%s.%s"%(self.classname, self.key)
+        return self.db._db.view("_%s" % tablename).ordered(1)
+
+    #
+    # import / export
+    #
+    def export_list(self, propnames, nodeid):
+        ''' Export a node - generate a list of CSV-able data in the order
+            specified by propnames for the given node.
+        '''
+        properties = self.getprops()
+        l = []
+        for prop in propnames:
+            proptype = properties[prop]
+            value = self.get(nodeid, prop)
+            # "marshal" data where needed
+            if value is None:
+                pass
+            elif isinstance(proptype, hyperdb.Date):
+                value = value.get_tuple()
+            elif isinstance(proptype, hyperdb.Interval):
+                value = value.get_tuple()
+            elif isinstance(proptype, hyperdb.Password):
+                value = str(value)
+            l.append(repr(value))
+
+        # append retired flag
+        l.append(repr(self.is_retired(nodeid)))
+
+        return l
+
+    def import_list(self, propnames, proplist):
+        ''' Import a node - all information including "id" is present and
+            should not be sanity checked. Triggers are not triggered. The
+            journal should be initialised using the "creator" and "creation"
+            information.
+
+            Return the nodeid of the node imported.
+        '''
+        if self.db.journaltag is None:
+            raise hyperdb.DatabaseError, 'Database open read-only'
+        properties = self.getprops()
+
+        d = {}
+        view = self.getview(READWRITE)
+        for i in range(len(propnames)):
+            value = eval(proplist[i])
+            if not value:
+                continue
+
+            propname = propnames[i]
+            if propname == 'id':
+                newid = value = int(value)
+            elif propname == 'is retired':
+                # is the item retired?
+                if int(value):
+                    d['_isdel'] = 1
+                continue
+            elif value is None:
+                d[propname] = None
+                continue
+
+            prop = properties[propname]
+            if isinstance(prop, hyperdb.Date):
+                value = int(calendar.timegm(value))
+            elif isinstance(prop, hyperdb.Interval):
+                value = date.Interval(value).serialise()
+            elif isinstance(prop, hyperdb.Number):
+                value = float(value)
+            elif isinstance(prop, hyperdb.Boolean):
+                value = int(value)
+            elif isinstance(prop, hyperdb.Link) and value:
+                value = int(value)
+            elif isinstance(prop, hyperdb.Multilink):
+                # we handle multilinks separately
+                continue
+            d[propname] = value
+
+        # possibly make a new node
+        if not d.has_key('id'):
+            d['id'] = newid = self.maxid
+            self.maxid += 1
+
+        # save off the node
+        view.append(d)
+
+        # fix up multilinks
+        ndx = view.find(id=newid)
+        row = view[ndx]
+        for i in range(len(propnames)):
+            value = eval(proplist[i])
+            propname = propnames[i]
+            if propname == 'is retired':
+                continue
+            prop = properties[propname]
+            if not isinstance(prop, hyperdb.Multilink):
+                continue
+            sv = getattr(row, propname)
+            for entry in value:
+                sv.append((int(entry),))
+
+        self.db.dirty = 1
+        return newid
+
+    def export_journals(self):
+        '''Export a class's journal - generate a list of lists of
+        CSV-able data:
+
+            nodeid, date, user, action, params
+
+        No heading here - the columns are fixed.
+        '''
+        from roundup.hyperdb import Interval, Date, Password
+        properties = self.getprops()
+        r = []
+        for nodeid in self.getnodeids():
+            for nodeid, date, user, action, params in self.history(nodeid):
+                date = date.get_tuple()
+                if action == 'set':
+                    export_data = {}
+                    for propname, value in params.items():
+                        if not properties.has_key(propname):
+                            # property no longer in the schema
+                            continue
+
+                        prop = properties[propname]
+                        # make sure the params are eval()'able
+                        if value is None:
+                            pass
+                        elif isinstance(prop, Date):
+                            value = value.get_tuple()
+                        elif isinstance(prop, Interval):
+                            value = value.get_tuple()
+                        elif isinstance(prop, Password):
+                            value = str(value)
+                        export_data[propname] = value
+                    params = export_data
+                l = [nodeid, date, user, action, params]
+                r.append(map(repr, l))
+        return r
+
+    def import_journals(self, entries):
+        '''Import a class's journal.
+
+        Uses setjournal() to set the journal for each item.'''
+        properties = self.getprops()
+        d = {}
+        for l in entries:
+            l = map(eval, l)
+            nodeid, jdate, user, action, params = l
+            jdate = int(calendar.timegm(date.Date(jdate).get_tuple()))
+            r = d.setdefault(nodeid, [])
+            if action == 'set':
+                for propname, value in params.items():
+                    prop = properties[propname]
+                    if value is None:
+                        pass
+                    elif isinstance(prop, hyperdb.Date):
+                        value = date.Date(value)
+                    elif isinstance(prop, hyperdb.Interval):
+                        value = date.Interval(value)
+                    elif isinstance(prop, hyperdb.Password):
+                        pwd = password.Password()
+                        pwd.unpack(value)
+                        value = pwd
+                    params[propname] = value
+            action = _names_to_actionnames[action]
+            r.append((nodeid, jdate, user, action, params))
+
+        for nodeid, l in d.items():
+            self.db.setjournal(self.classname, nodeid, l)
+
+def _fetchML(sv):
+    l = []
+    for row in sv:
+        if row.fid:
+            l.append(str(row.fid))
+    return l
+
+def _fetchPW(s):
+    ''' Convert to a password.Password unless the password is '' which is
+        our sentinel for "unset".
+    '''
+    if s == '':
+        return None
+    p = password.Password()
+    p.unpack(s)
+    return p
+
+def _fetchLink(n):
+    ''' Return None if the link is 0 - otherwise strify it.
+    '''
+    return n and str(n) or None
+
+def _fetchDate(n):
+    ''' Convert the timestamp to a date.Date instance - unless it's 0 which
+        is our sentinel for "unset".
+    '''
+    if n == 0:
+        return None
+    return date.Date(time.gmtime(n))
+
+def _fetchInterval(n):
+    ''' Convert to a date.Interval unless the interval is '' which is our
+        sentinel for "unset".
+    '''
+    if n == '':
+        return None
+    return date.Interval(n)
+
+# Converters for boolean and numbers to properly
+# return None values.
+# These are in conjunction with the setters above
+#  look for hyperdb.Boolean and hyperdb.Number
+if BACKWARDS_COMPATIBLE:
+    def getBoolean(bool): return bool
+    def getNumber(number): return number
+else:
+    def getBoolean(bool):
+        if not bool: res = None
+        else: res = bool - 1
+        return res
+
+    def getNumber(number):
+        if number == 0: res = None
+        elif number < 0: res = number
+        else: res = number - 1
+        return res
+
+_converters = {
+    hyperdb.Date   : _fetchDate,
+    hyperdb.Link   : _fetchLink,
+    hyperdb.Multilink : _fetchML,
+    hyperdb.Interval  : _fetchInterval,
+    hyperdb.Password  : _fetchPW,
+    hyperdb.Boolean   : getBoolean,
+    hyperdb.Number    : getNumber,
+    hyperdb.String    : lambda s: s and str(s) or None,
+}
+
+class FileName(hyperdb.String):
+    isfilename = 1
+
+_typmap = {
+    FileName : 'S',
+    hyperdb.String : 'S',
+    hyperdb.Date   : 'I',
+    hyperdb.Link   : 'I',
+    hyperdb.Multilink : 'V',
+    hyperdb.Interval  : 'S',
+    hyperdb.Password  : 'S',
+    hyperdb.Boolean   : 'I',
+    hyperdb.Number    : 'D',
+}
+class FileClass(hyperdb.FileClass, Class):
+    ''' like Class but with a content property
+    '''
+    def __init__(self, db, classname, **properties):
+        '''The newly-created class automatically includes the "content"
+        and "type" properties.
+        '''
+        if not properties.has_key('content'):
+            properties['content'] = hyperdb.String(indexme='yes')
+        if not properties.has_key('type'):
+            properties['type'] = hyperdb.String()
+        Class.__init__(self, db, classname, **properties)
+
+    def gen_filename(self, nodeid):
+        nm = '%s%s' % (self.classname, nodeid)
+        sd = str(int(int(nodeid) / 1000))
+        d = os.path.join(self.db.config.DATABASE, 'files', self.classname, sd)
+        if not os.path.exists(d):
+            os.makedirs(d)
+        return os.path.join(d, nm)
+
+    def export_files(self, dirname, nodeid):
+        ''' Export the "content" property as a file, not csv column
+        '''
+        source = self.gen_filename(nodeid)
+        x, filename = os.path.split(source)
+        x, subdir = os.path.split(x)
+        dest = os.path.join(dirname, self.classname+'-files', subdir, filename)
+        if not os.path.exists(os.path.dirname(dest)):
+            os.makedirs(os.path.dirname(dest))
+        shutil.copyfile(source, dest)
+
+    def import_files(self, dirname, nodeid):
+        ''' Import the "content" property as a file
+        '''
+        dest = self.gen_filename(nodeid)
+        x, filename = os.path.split(dest)
+        x, subdir = os.path.split(x)
+        source = os.path.join(dirname, self.classname+'-files', subdir,
+            filename)
+        if not os.path.exists(os.path.dirname(dest)):
+            os.makedirs(os.path.dirname(dest))
+        shutil.copyfile(source, dest)
+
+        mime_type = None
+        if self.getprops().has_key('type'):
+            mime_type = self.get(nodeid, 'type')
+        if not mime_type:
+            mime_type = self.default_mime_type
+        self.db.indexer.add_text((self.classname, nodeid, 'content'),
+            self.get(nodeid, 'content'), mime_type)
+
+    def get(self, nodeid, propname, default=_marker, cache=1):
+        if propname == 'content':
+            poss_msg = 'Possibly an access right configuration problem.'
+            fnm = self.gen_filename(nodeid)
+            if not os.path.exists(fnm):
+                fnm = fnm + '.tmp'
+            try:
+                f = open(fnm, 'rb')
+            except IOError, (strerror):
+                # XXX by catching this we donot see an error in the log.
+                return 'ERROR reading file: %s%s\n%s\n%s'%(
+                        self.classname, nodeid, poss_msg, strerror)
+            x = f.read()
+            f.close()
+        else:
+            x = Class.get(self, nodeid, propname, default)
+        return x
+
+    def create(self, **propvalues):
+        if not propvalues:
+            raise ValueError, "Need something to create!"
+        self.fireAuditors('create', None, propvalues)
+
+        content = propvalues['content']
+        del propvalues['content']
+
+        newid = Class.create_inner(self, **propvalues)
+        if not content:
+            return newid
+
+        # figure a filename
+        nm = self.gen_filename(newid)
+
+        # make sure we don't register the rename action more than once
+        if not os.path.exists(nm + '.tmp'):
+            # register commit and rollback actions
+            def commit(fnm=nm):
+                os.rename(fnm + '.tmp', fnm)
+            self.commitaction(commit)
+            def undo(fnm=nm):
+                os.remove(fnm + '.tmp')
+            self.rollbackaction(undo)
+
+        # save the tempfile
+        f = open(nm + '.tmp', 'wb')
+        f.write(content)
+        f.close()
+
+        mimetype = propvalues.get('type', self.default_mime_type)
+        self.db.indexer.add_text((self.classname, newid, 'content'), content,
+            mimetype)
+        return newid
+
+    def set(self, itemid, **propvalues):
+        if not propvalues:
+            return
+        self.fireAuditors('set', None, propvalues)
+
+        content = propvalues.get('content', None)
+        if content is not None:
+            del propvalues['content']
+
+        propvalues, oldnode = Class.set_inner(self, itemid, **propvalues)
+
+        # figure a filename
+        if content is not None:
+            nm = self.gen_filename(itemid)
+
+            # make sure we don't register the rename action more than once
+            if not os.path.exists(nm + '.tmp'):
+                # register commit and rollback actions
+                def commit(fnm=nm):
+                    if os.path.exists(fnm):
+                        os.remove(fnm)
+                    os.rename(fnm + '.tmp', fnm)
+                self.commitaction(commit)
+                def undo(fnm=nm):
+                    os.remove(fnm + '.tmp')
+                self.rollbackaction(undo)
+
+            f = open(nm + '.tmp', 'wb')
+            f.write(content)
+            f.close()
+
+            if self.properties['content'].indexme:
+                mimetype = self.get('type', self.default_mime_type)
+                self.db.indexer.add_text((self.classname, itemid, 'content'),
+                    content, mimetype)
+
+        self.fireReactors('set', oldnode, propvalues)
+
+    def index(self, nodeid):
+        ''' Add (or refresh) the node to search indexes.
+
+        Use the content-type property for the content property.
+        '''
+        # find all the String properties that have indexme
+        for prop, propclass in self.getprops().items():
+            if prop == 'content' and propclass.indexme:
+                mime_type = self.get(nodeid, 'type', self.default_mime_type)
+                self.db.indexer.add_text((self.classname, nodeid, 'content'),
+                    str(self.get(nodeid, 'content')), mime_type)
+            elif isinstance(propclass, hyperdb.String) and propclass.indexme:
+                # index them under (classname, nodeid, property)
+                try:
+                    value = str(self.get(nodeid, prop))
+                except IndexError:
+                    # node has been destroyed
+                    continue
+                self.db.indexer.add_text((self.classname, nodeid, prop), value)
+
+class IssueClass(Class, roundupdb.IssueClass):
+    ''' The newly-created class automatically includes the "messages",
+        "files", "nosy", and "superseder" properties.  If the 'properties'
+        dictionary attempts to specify any of these properties or a
+        "creation" or "activity" property, a ValueError is raised.
+    '''
+    def __init__(self, db, classname, **properties):
+        if not properties.has_key('title'):
+            properties['title'] = hyperdb.String(indexme='yes')
+        if not properties.has_key('messages'):
+            properties['messages'] = hyperdb.Multilink("msg")
+        if not properties.has_key('files'):
+            properties['files'] = hyperdb.Multilink("file")
+        if not properties.has_key('nosy'):
+            # note: journalling is turned off as it really just wastes
+            # space. this behaviour may be overridden in an instance
+            properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
+        if not properties.has_key('superseder'):
+            properties['superseder'] = hyperdb.Multilink(classname)
+        Class.__init__(self, db, classname, **properties)
+
+CURVERSION = 2
+
+class MetakitIndexer(Indexer):
+    def __init__(self, db):
+        self.path = os.path.join(db.config.DATABASE, 'index.mk4')
+        self.db = metakit.storage(self.path, 1)
+        self.datadb = db._db
+        self.reindex = 0
+        v = self.db.view('version')
+        if not v.structure():
+            v = self.db.getas('version[vers:I]')
+            self.db.commit()
+            v.append(vers=CURVERSION)
+            self.reindex = 1
+        elif v[0].vers != CURVERSION:
+            v[0].vers = CURVERSION
+            self.reindex = 1
+        if self.reindex:
+            self.db.getas('ids[tblid:I,nodeid:I,propid:I,ignore:I]')
+            self.db.getas('index[word:S,hits[pos:I]]')
+            self.db.commit()
+            self.reindex = 1
+        self.changed = 0
+        self.propcache = {}
+
+    def close(self):
+        '''close the indexing database'''
+        del self.db
+        self.db = None
+
+    def force_reindex(self):
+        '''Force a reindexing of the database.  This essentially
+        empties the tables ids and index and sets a flag so
+        that the databases are reindexed'''
+        v = self.db.view('ids')
+        v[:] = []
+        v = self.db.view('index')
+        v[:] = []
+        self.db.commit()
+        self.reindex = 1
+
+    def should_reindex(self):
+        '''returns True if the indexes need to be rebuilt'''
+        return self.reindex
+
+    def _getprops(self, classname):
+        props = self.propcache.get(classname, None)
+        if props is None:
+            props = self.datadb.view(classname).structure()
+            props = [prop.name for prop in props]
+            self.propcache[classname] = props
+        return props
+
+    def _getpropid(self, classname, propname):
+        return self._getprops(classname).index(propname)
+
+    def _getpropname(self, classname, propid):
+        return self._getprops(classname)[propid]
+
+    def add_text(self, identifier, text, mime_type='text/plain'):
+        if mime_type != 'text/plain':
+            return
+        classname, nodeid, property = identifier
+        tbls = self.datadb.view('tables')
+        tblid = tbls.find(name=classname)
+        if tblid < 0:
+            raise KeyError, "unknown class %r"%classname
+        nodeid = int(nodeid)
+        propid = self._getpropid(classname, property)
+        ids = self.db.view('ids')
+        oldpos = ids.find(tblid=tblid,nodeid=nodeid,propid=propid,ignore=0)
+        if oldpos > -1:
+            ids[oldpos].ignore = 1
+            self.changed = 1
+        pos = ids.append(tblid=tblid,nodeid=nodeid,propid=propid)
+
+        wordlist = re.findall(r'\b\w{2,25}\b', text.upper())
+        words = {}
+        for word in wordlist:
+            if not self.is_stopword(word):
+                words[word] = 1
+        words = words.keys()
+
+        index = self.db.view('index').ordered(1)
+        for word in words:
+            ndx = index.find(word=word)
+            if ndx < 0:
+                index.append(word=word)
+                ndx = index.find(word=word)
+            index[ndx].hits.append(pos=pos)
+            self.changed = 1
+
+    def find(self, wordlist):
+        '''look up all the words in the wordlist.
+        If none are found return an empty dictionary
+        * more rules here
+        '''
+        hits = None
+        index = self.db.view('index').ordered(1)
+        for word in wordlist:
+            word = word.upper()
+            if not 2 < len(word) < 26:
+                continue
+            ndx = index.find(word=word)
+            if ndx < 0:
+                return {}
+            if hits is None:
+                hits = index[ndx].hits
+            else:
+                hits = hits.intersect(index[ndx].hits)
+            if len(hits) == 0:
+                return {}
+        if hits is None:
+            return []
+        rslt = []
+        ids = self.db.view('ids').remapwith(hits)
+        tbls = self.datadb.view('tables')
+        for i in range(len(ids)):
+            hit = ids[i]
+            if not hit.ignore:
+                classname = tbls[hit.tblid].name
+                nodeid = str(hit.nodeid)
+                property = self._getpropname(classname, hit.propid)
+                rslt.append((classname, nodeid, property))
+        return rslt
+
+    def save_index(self):
+        if self.changed:
+            self.db.commit()
+        self.changed = 0
+
+    def rollback(self):
+        if self.changed:
+            self.db.rollback()
+            self.db = metakit.storage(self.path, 1)
+        self.changed = 0
+
+try:
+    from indexer_xapian import Indexer
+except ImportError:
+    Indexer = MetakitIndexer
+
+# vim: set et sts=4 sw=4 :

Added: tracker/vendor/roundup/current/roundup/backends/back_mysql.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/backends/back_mysql.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,830 @@
+#$Id: back_mysql.py,v 1.68 2006/04/27 03:40:42 richard Exp $
+#
+# Copyright (c) 2003 Martynas Sklyzmantas, Andrey Lebedev <andrey at micro.lt>
+#
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+
+'''This module defines a backend implementation for MySQL.
+
+
+How to implement AUTO_INCREMENT:
+
+mysql> create table foo (num integer auto_increment primary key, name
+varchar(255)) AUTO_INCREMENT=1 type=InnoDB;
+
+ql> insert into foo (name) values ('foo5');
+Query OK, 1 row affected (0.00 sec)
+
+mysql> SELECT num FROM foo WHERE num IS NULL;
++-----+
+| num |
++-----+
+|   4 |
++-----+
+1 row in set (0.00 sec)
+
+mysql> SELECT num FROM foo WHERE num IS NULL;
+Empty set (0.00 sec)
+
+NOTE: we don't need an index on the id column if it's PRIMARY KEY
+
+'''
+__docformat__ = 'restructuredtext'
+
+from roundup.backends.rdbms_common import *
+from roundup.backends import rdbms_common
+import MySQLdb
+import os, shutil
+from MySQLdb.constants import ER
+import logging
+
+def connection_dict(config, dbnamestr=None):
+    d = rdbms_common.connection_dict(config, dbnamestr)
+    if d.has_key('password'):
+        d['passwd'] = d['password']
+        del d['password']
+    if d.has_key('port'):
+        d['port'] = int(d['port'])
+    return d
+
+def db_nuke(config):
+    """Clear all database contents and drop database itself"""
+    if db_exists(config):
+        kwargs = connection_dict(config)
+        conn = MySQLdb.connect(**kwargs)
+        try:
+            conn.select_db(config.RDBMS_NAME)
+        except:
+            # no, it doesn't exist
+            pass
+        else:
+            cursor = conn.cursor()
+            cursor.execute("SHOW TABLES")
+            tables = cursor.fetchall()
+            # stupid MySQL bug requires us to drop all the tables first
+            for table in tables:
+                command = 'DROP TABLE `%s`'%table[0]
+                if __debug__:
+                    logging.getLogger('hyperdb').debug(command)
+                cursor.execute(command)
+            command = "DROP DATABASE %s"%config.RDBMS_NAME
+            logging.getLogger('hyperdb').info(command)
+            cursor.execute(command)
+            conn.commit()
+        conn.close()
+
+    if os.path.exists(config.DATABASE):
+        shutil.rmtree(config.DATABASE)
+
+def db_create(config):
+    """Create the database."""
+    kwargs = connection_dict(config)
+    conn = MySQLdb.connect(**kwargs)
+    cursor = conn.cursor()
+    command = "CREATE DATABASE %s"%config.RDBMS_NAME
+    logging.getLogger('hyperdb').info(command)
+    cursor.execute(command)
+    conn.commit()
+    conn.close()
+
+def db_exists(config):
+    """Check if database already exists."""
+    kwargs = connection_dict(config)
+    conn = MySQLdb.connect(**kwargs)
+    try:
+        try:
+            conn.select_db(config.RDBMS_NAME)
+        except MySQLdb.OperationalError:
+            return 0
+    finally:
+        conn.close()
+    return 1
+
+
+class Database(Database):
+    arg = '%s'
+
+    # used by some code to switch styles of query
+    implements_intersect = 0
+
+    # Backend for MySQL to use.
+    # InnoDB is faster, but if you're running <4.0.16 then you'll need to
+    # use BDB to pass all unit tests.
+    mysql_backend = 'InnoDB'
+    #mysql_backend = 'BDB'
+
+    hyperdb_to_sql_datatypes = {
+        hyperdb.String : 'TEXT',
+        hyperdb.Date   : 'DATETIME',
+        hyperdb.Link   : 'INTEGER',
+        hyperdb.Interval  : 'VARCHAR(255)',
+        hyperdb.Password  : 'VARCHAR(255)',
+        hyperdb.Boolean   : 'BOOL',
+        hyperdb.Number    : 'REAL',
+    }
+
+    hyperdb_to_sql_value = {
+        hyperdb.String : str,
+        # no fractional seconds for MySQL
+        hyperdb.Date   : lambda x: x.formal(sep=' '),
+        hyperdb.Link   : int,
+        hyperdb.Interval  : str,
+        hyperdb.Password  : str,
+        hyperdb.Boolean   : int,
+        hyperdb.Number    : lambda x: x,
+        hyperdb.Multilink : lambda x: x,    # used in journal marshalling
+    }
+
+    def sql_open_connection(self):
+        kwargs = connection_dict(self.config, 'db')
+        logging.getLogger('hyperdb').info('open database %r'%(kwargs['db'],))
+        try:
+            conn = MySQLdb.connect(**kwargs)
+        except MySQLdb.OperationalError, message:
+            raise DatabaseError, message
+        cursor = conn.cursor()
+        cursor.execute("SET AUTOCOMMIT=0")
+        cursor.execute("START TRANSACTION")
+        return (conn, cursor)
+
+    def open_connection(self):
+        # make sure the database actually exists
+        if not db_exists(self.config):
+            db_create(self.config)
+
+        self.conn, self.cursor = self.sql_open_connection()
+
+        try:
+            self.load_dbschema()
+        except MySQLdb.OperationalError, message:
+            if message[0] != ER.NO_DB_ERROR:
+                raise
+        except MySQLdb.ProgrammingError, message:
+            if message[0] != ER.NO_SUCH_TABLE:
+                raise DatabaseError, message
+            self.init_dbschema()
+            self.sql("CREATE TABLE `schema` (`schema` TEXT) TYPE=%s"%
+                self.mysql_backend)
+            self.sql('''CREATE TABLE ids (name VARCHAR(255),
+                num INTEGER) TYPE=%s'''%self.mysql_backend)
+            self.sql('create index ids_name_idx on ids(name)')
+            self.create_version_2_tables()
+
+    def load_dbschema(self):
+        ''' Load the schema definition that the database currently implements
+        '''
+        self.cursor.execute('select `schema` from `schema`')
+        schema = self.cursor.fetchone()
+        if schema:
+            self.database_schema = eval(schema[0])
+        else:
+            self.database_schema = {}
+
+    def save_dbschema(self):
+        ''' Save the schema definition that the database currently implements
+        '''
+        s = repr(self.database_schema)
+        self.sql('delete from `schema`')
+        self.sql('insert into `schema` values (%s)', (s,))
+
+    def create_version_2_tables(self):
+        # OTK store
+        self.sql('''CREATE TABLE otks (otk_key VARCHAR(255),
+            otk_value TEXT, otk_time FLOAT(20))
+            TYPE=%s'''%self.mysql_backend)
+        self.sql('CREATE INDEX otks_key_idx ON otks(otk_key)')
+
+        # Sessions store
+        self.sql('''CREATE TABLE sessions (session_key VARCHAR(255),
+            session_time FLOAT(20), session_value TEXT)
+            TYPE=%s'''%self.mysql_backend)
+        self.sql('''CREATE INDEX sessions_key_idx ON
+            sessions(session_key)''')
+
+        # full-text indexing store
+        self.sql('''CREATE TABLE __textids (_class VARCHAR(255),
+            _itemid VARCHAR(255), _prop VARCHAR(255), _textid INT)
+            TYPE=%s'''%self.mysql_backend)
+        self.sql('''CREATE TABLE __words (_word VARCHAR(30),
+            _textid INT) TYPE=%s'''%self.mysql_backend)
+        self.sql('CREATE INDEX words_word_ids ON __words(_word)')
+        sql = 'insert into ids (name, num) values (%s,%s)'%(self.arg, self.arg)
+        self.sql(sql, ('__textids', 1))
+
+    def add_new_columns_v2(self):
+        '''While we're adding the actor column, we need to update the
+        tables to have the correct datatypes.'''
+        for klass in self.classes.values():
+            cn = klass.classname
+            properties = klass.getprops()
+            old_spec = self.database_schema['tables'][cn]
+
+            # figure the non-Multilink properties to copy over
+            propnames = ['activity', 'creation', 'creator']
+
+            # figure actions based on data type
+            for name, s_prop in old_spec[1]:
+                # s_prop is a repr() string of a hyperdb type object
+                if s_prop.find('Multilink') == -1:
+                    if properties.has_key(name):
+                        propnames.append(name)
+                    continue
+                tn = '%s_%s'%(cn, name)
+
+                if properties.has_key(name):
+                    # grabe the current values
+                    sql = 'select linkid, nodeid from %s'%tn
+                    self.sql(sql)
+                    rows = self.cursor.fetchall()
+
+                # drop the old table
+                self.drop_multilink_table_indexes(cn, name)
+                sql = 'drop table %s'%tn
+                self.sql(sql)
+
+                if properties.has_key(name):
+                    # re-create and populate the new table
+                    self.create_multilink_table(klass, name)
+                    sql = '''insert into %s (linkid, nodeid) values
+                        (%s, %s)'''%(tn, self.arg, self.arg)
+                    for linkid, nodeid in rows:
+                        self.sql(sql, (int(linkid), int(nodeid)))
+
+            # figure the column names to fetch
+            fetch = ['_%s'%name for name in propnames]
+
+            # select the data out of the old table
+            fetch.append('id')
+            fetch.append('__retired__')
+            fetchcols = ','.join(fetch)
+            sql = 'select %s from _%s'%(fetchcols, cn)
+            self.sql(sql)
+
+            # unserialise the old data
+            olddata = []
+            propnames = propnames + ['id', '__retired__']
+            cols = []
+            first = 1
+            for entry in self.cursor.fetchall():
+                l = []
+                olddata.append(l)
+                for i in range(len(propnames)):
+                    name = propnames[i]
+                    v = entry[i]
+
+                    if name in ('id', '__retired__'):
+                        if first:
+                            cols.append(name)
+                        l.append(int(v))
+                        continue
+                    if first:
+                        cols.append('_' + name)
+                    prop = properties[name]
+                    if isinstance(prop, Date) and v is not None:
+                        v = date.Date(v)
+                    elif isinstance(prop, Interval) and v is not None:
+                        v = date.Interval(v)
+                    elif isinstance(prop, Password) and v is not None:
+                        v = password.Password(encrypted=v)
+                    elif (isinstance(prop, Boolean) or
+                            isinstance(prop, Number)) and v is not None:
+                        v = float(v)
+
+                    # convert to new MySQL data type
+                    prop = properties[name]
+                    if v is not None:
+                        e = self.hyperdb_to_sql_value[prop.__class__](v)
+                    else:
+                        e = None
+                    l.append(e)
+
+                    # Intervals store the seconds value too
+                    if isinstance(prop, Interval):
+                        if first:
+                            cols.append('__' + name + '_int__')
+                        if v is not None:
+                            l.append(v.as_seconds())
+                        else:
+                            l.append(e)
+                first = 0
+
+            self.drop_class_table_indexes(cn, old_spec[0])
+
+            # drop the old table
+            self.sql('drop table _%s'%cn)
+
+            # create the new table
+            self.create_class_table(klass)
+
+            # do the insert of the old data
+            args = ','.join([self.arg for x in cols])
+            cols = ','.join(cols)
+            sql = 'insert into _%s (%s) values (%s)'%(cn, cols, args)
+            for entry in olddata:
+                self.sql(sql, tuple(entry))
+
+            # now load up the old journal data to migrate it
+            cols = ','.join('nodeid date tag action params'.split())
+            sql = 'select %s from %s__journal'%(cols, cn)
+            self.sql(sql)
+
+            # data conversions
+            olddata = []
+            for nodeid, journaldate, journaltag, action, params in \
+                    self.cursor.fetchall():
+                #nodeid = int(nodeid)
+                journaldate = date.Date(journaldate)
+                #params = eval(params)
+                olddata.append((nodeid, journaldate, journaltag, action,
+                    params))
+
+            # drop journal table and indexes
+            self.drop_journal_table_indexes(cn)
+            sql = 'drop table %s__journal'%cn
+            self.sql(sql)
+
+            # re-create journal table
+            self.create_journal_table(klass)
+            dc = self.hyperdb_to_sql_value[hyperdb.Date]
+            for nodeid, journaldate, journaltag, action, params in olddata:
+                self.save_journal(cn, cols, nodeid, dc(journaldate),
+                    journaltag, action, params)
+
+            # make sure the normal schema update code doesn't try to
+            # change things
+            self.database_schema['tables'][cn] = klass.schema()
+
+    def fix_version_2_tables(self):
+        # Convert journal date column to TIMESTAMP, params column to TEXT
+        self._convert_journal_tables()
+
+        # Convert all String properties to TEXT
+        self._convert_string_properties()
+
+    def __repr__(self):
+        return '<myroundsql 0x%x>'%id(self)
+
+    def sql_fetchone(self):
+        return self.cursor.fetchone()
+
+    def sql_fetchall(self):
+        return self.cursor.fetchall()
+
+    def sql_index_exists(self, table_name, index_name):
+        self.sql('show index from %s'%table_name)
+        for index in self.cursor.fetchall():
+            if index[2] == index_name:
+                return 1
+        return 0
+
+    def create_class_table(self, spec, create_sequence=1):
+        cols, mls = self.determine_columns(spec.properties.items())
+
+        # add on our special columns
+        cols.append(('id', 'INTEGER PRIMARY KEY'))
+        cols.append(('__retired__', 'INTEGER DEFAULT 0'))
+
+        # create the base table
+        scols = ','.join(['%s %s'%x for x in cols])
+        sql = 'create table _%s (%s) type=%s'%(spec.classname, scols,
+            self.mysql_backend)
+        self.sql(sql)
+
+        self.create_class_table_indexes(spec)
+        return cols, mls
+
+    def create_class_table_indexes(self, spec):
+        ''' create the class table for the given spec
+        '''
+        # create __retired__ index
+        index_sql2 = 'create index _%s_retired_idx on _%s(__retired__)'%(
+                        spec.classname, spec.classname)
+        self.sql(index_sql2)
+
+        # create index for key property
+        if spec.key:
+            if isinstance(spec.properties[spec.key], String):
+                idx = spec.key + '(255)'
+            else:
+                idx = spec.key
+            index_sql3 = 'create index _%s_%s_idx on _%s(_%s)'%(
+                        spec.classname, spec.key,
+                        spec.classname, idx)
+            self.sql(index_sql3)
+
+        # TODO: create indexes on (selected?) Link property columns, as
+        # they're more likely to be used for lookup
+
+    def create_class_table_key_index(self, cn, key):
+        ''' create the class table for the given spec
+        '''
+        prop = self.classes[cn].getprops()[key]
+        if isinstance(prop, String):
+            sql = 'create index _%s_%s_idx on _%s(_%s(255))'%(cn, key, cn, key)
+        else:
+            sql = 'create index _%s_%s_idx on _%s(_%s)'%(cn, key, cn, key)
+        self.sql(sql)
+
+    def drop_class_table_indexes(self, cn, key):
+        # drop the old table indexes first
+        l = ['_%s_id_idx'%cn, '_%s_retired_idx'%cn]
+        if key:
+            l.append('_%s_%s_idx'%(cn, key))
+
+        table_name = '_%s'%cn
+        for index_name in l:
+            if not self.sql_index_exists(table_name, index_name):
+                continue
+            index_sql = 'drop index %s on %s'%(index_name, table_name)
+            self.sql(index_sql)
+
+    def create_journal_table(self, spec):
+        ''' create the journal table for a class given the spec and
+            already-determined cols
+        '''
+        # journal table
+        cols = ','.join(['%s varchar'%x
+            for x in 'nodeid date tag action params'.split()])
+        sql = '''create table %s__journal (
+            nodeid integer, date datetime, tag varchar(255),
+            action varchar(255), params text) type=%s'''%(
+            spec.classname, self.mysql_backend)
+        self.sql(sql)
+        self.create_journal_table_indexes(spec)
+
+    def drop_journal_table_indexes(self, classname):
+        index_name = '%s_journ_idx'%classname
+        if not self.sql_index_exists('%s__journal'%classname, index_name):
+            return
+        index_sql = 'drop index %s on %s__journal'%(index_name, classname)
+        self.sql(index_sql)
+
+    def create_multilink_table(self, spec, ml):
+        sql = '''CREATE TABLE `%s_%s` (linkid VARCHAR(255),
+            nodeid VARCHAR(255)) TYPE=%s'''%(spec.classname, ml,
+                self.mysql_backend)
+        self.sql(sql)
+        self.create_multilink_table_indexes(spec, ml)
+
+    def drop_multilink_table_indexes(self, classname, ml):
+        l = [
+            '%s_%s_l_idx'%(classname, ml),
+            '%s_%s_n_idx'%(classname, ml)
+        ]
+        table_name = '%s_%s'%(classname, ml)
+        for index_name in l:
+            if not self.sql_index_exists(table_name, index_name):
+                continue
+            sql = 'drop index %s on %s'%(index_name, table_name)
+            self.sql(sql)
+
+    def drop_class_table_key_index(self, cn, key):
+        table_name = '_%s'%cn
+        index_name = '_%s_%s_idx'%(cn, key)
+        if not self.sql_index_exists(table_name, index_name):
+            return
+        sql = 'drop index %s on %s'%(index_name, table_name)
+        self.sql(sql)
+
+    # old-skool id generation
+    def newid(self, classname):
+        ''' Generate a new id for the given class
+        '''
+        # get the next ID - "FOR UPDATE" will lock the row for us
+        sql = 'select num from ids where name=%s FOR UPDATE'%self.arg
+        self.sql(sql, (classname, ))
+        newid = int(self.cursor.fetchone()[0])
+
+        # update the counter
+        sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
+        vals = (int(newid)+1, classname)
+        self.sql(sql, vals)
+
+        # return as string
+        return str(newid)
+
+    def setid(self, classname, setid):
+        ''' Set the id counter: used during import of database
+
+        We add one to make it behave like the seqeunces in postgres.
+        '''
+        sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
+        vals = (int(setid)+1, classname)
+        self.sql(sql, vals)
+
+    def clear(self):
+        rdbms_common.Database.clear(self)
+
+        # set the id counters to 0 (setid adds one) so we start at 1
+        for cn in self.classes.keys():
+            self.setid(cn, 0)
+
+    def create_class(self, spec):
+        rdbms_common.Database.create_class(self, spec)
+        sql = 'insert into ids (name, num) values (%s, %s)'
+        vals = (spec.classname, 1)
+        self.sql(sql, vals)
+
+    def sql_commit(self):
+        ''' Actually commit to the database.
+        '''
+        logging.getLogger('hyperdb').info('commit')
+        self.conn.commit()
+
+        # open a new cursor for subsequent work
+        self.cursor = self.conn.cursor()
+
+        # make sure we're in a new transaction and not autocommitting
+        self.sql("SET AUTOCOMMIT=0")
+        self.sql("START TRANSACTION")
+
+    def sql_close(self):
+        logging.getLogger('hyperdb').info('close')
+        try:
+            self.conn.close()
+        except MySQLdb.ProgrammingError, message:
+            if str(message) != 'closing a closed connection':
+                raise
+
+class MysqlClass:
+    # we're overriding this method for ONE missing bit of functionality.
+    # look for "I can't believe it's not a toy RDBMS" below
+    def filter(self, search_matches, filterspec, sort=(None,None),
+            group=(None,None)):
+        '''Return a list of the ids of the active nodes in this class that
+        match the 'filter' spec, sorted by the group spec and then the
+        sort spec
+
+        "filterspec" is {propname: value(s)}
+
+        "sort" and "group" are (dir, prop) where dir is '+', '-' or None
+        and prop is a prop name or None
+
+        "search_matches" is {nodeid: marker} or None
+
+        The filter must match all properties specificed. If the property
+        value to match is a list:
+
+        1. String properties must match all elements in the list, and
+        2. Other properties must match any of the elements in the list.
+        '''
+        # we can't match anything if search_matches is empty
+        if search_matches == {}:
+            return []
+
+        if __debug__:
+            start_t = time.time()
+
+        cn = self.classname
+
+        # vars to hold the components of the SQL statement
+        frum = ['_'+cn] # FROM clauses
+        loj = []        # LEFT OUTER JOIN clauses
+        where = []      # WHERE clauses
+        args = []       # *any* positional arguments
+        a = self.db.arg
+
+        # figure the WHERE clause from the filterspec
+        props = self.getprops()
+        mlfilt = 0      # are we joining with Multilink tables?
+        for k, v in filterspec.items():
+            propclass = props[k]
+            # now do other where clause stuff
+            if isinstance(propclass, Multilink):
+                mlfilt = 1
+                tn = '%s_%s'%(cn, k)
+                if v in ('-1', ['-1']):
+                    # only match rows that have count(linkid)=0 in the
+                    # corresponding multilink table)
+
+                    # "I can't believe it's not a toy RDBMS"
+                    # see, even toy RDBMSes like gadfly and sqlite can do
+                    # sub-selects...
+                    self.db.sql('select nodeid from %s'%tn)
+                    s = ','.join([x[0] for x in self.db.sql_fetchall()])
+
+                    where.append('_%s.id not in (%s)'%(cn, s))
+                elif isinstance(v, type([])):
+                    frum.append(tn)
+                    s = ','.join([a for x in v])
+                    where.append('_%s.id=%s.nodeid and %s.linkid in (%s)'%(cn,
+                        tn, tn, s))
+                    args = args + v
+                else:
+                    frum.append(tn)
+                    where.append('_%s.id=%s.nodeid and %s.linkid=%s'%(cn, tn,
+                        tn, a))
+                    args.append(v)
+            elif k == 'id':
+                if isinstance(v, type([])):
+                    s = ','.join([a for x in v])
+                    where.append('_%s.%s in (%s)'%(cn, k, s))
+                    args = args + v
+                else:
+                    where.append('_%s.%s=%s'%(cn, k, a))
+                    args.append(v)
+            elif isinstance(propclass, String):
+                if not isinstance(v, type([])):
+                    v = [v]
+
+                # Quote the bits in the string that need it and then embed
+                # in a "substring" search. Note - need to quote the '%' so
+                # they make it through the python layer happily
+                v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
+
+                # now add to the where clause
+                where.append('('
+                    +' and '.join(["_%s._%s LIKE '%s'"%(cn, k, s) for s in v])
+                    +')')
+                # note: args are embedded in the query string now
+            elif isinstance(propclass, Link):
+                if isinstance(v, type([])):
+                    d = {}
+                    for entry in v:
+                        if entry == '-1':
+                            entry = None
+                        d[entry] = entry
+                    l = []
+                    if d.has_key(None) or not d:
+                        del d[None]
+                        l.append('_%s._%s is NULL'%(cn, k))
+                    if d:
+                        v = d.keys()
+                        s = ','.join([a for x in v])
+                        l.append('(_%s._%s in (%s))'%(cn, k, s))
+                        args = args + v
+                    if l:
+                        where.append('(' + ' or '.join(l) +')')
+                else:
+                    if v in ('-1', None):
+                        v = None
+                        where.append('_%s._%s is NULL'%(cn, k))
+                    else:
+                        where.append('_%s._%s=%s'%(cn, k, a))
+                        args.append(v)
+            elif isinstance(propclass, Date):
+                dc = self.db.hyperdb_to_sql_value[hyperdb.Date]
+                if isinstance(v, type([])):
+                    s = ','.join([a for x in v])
+                    where.append('_%s._%s in (%s)'%(cn, k, s))
+                    args = args + [dc(date.Date(x)) for x in v]
+                else:
+                    try:
+                        # Try to filter on range of dates
+                        date_rng = propclass.range_from_raw (v, self.db)
+                        if date_rng.from_value:
+                            where.append('_%s._%s >= %s'%(cn, k, a))
+                            args.append(dc(date_rng.from_value))
+                        if date_rng.to_value:
+                            where.append('_%s._%s <= %s'%(cn, k, a))
+                            args.append(dc(date_rng.to_value))
+                    except ValueError:
+                        # If range creation fails - ignore that search parameter
+                        pass
+            elif isinstance(propclass, Interval):
+                # filter using the __<prop>_int__ column
+                if isinstance(v, type([])):
+                    s = ','.join([a for x in v])
+                    where.append('_%s.__%s_int__ in (%s)'%(cn, k, s))
+                    args = args + [date.Interval(x).as_seconds() for x in v]
+                else:
+                    try:
+                        # Try to filter on range of intervals
+                        date_rng = Range(v, date.Interval)
+                        if date_rng.from_value:
+                            where.append('_%s.__%s_int__ >= %s'%(cn, k, a))
+                            args.append(date_rng.from_value.as_seconds())
+                        if date_rng.to_value:
+                            where.append('_%s.__%s_int__ <= %s'%(cn, k, a))
+                            args.append(date_rng.to_value.as_seconds())
+                    except ValueError:
+                        # If range creation fails - ignore that search parameter
+                        pass
+            else:
+                if isinstance(v, type([])):
+                    s = ','.join([a for x in v])
+                    where.append('_%s._%s in (%s)'%(cn, k, s))
+                    args = args + v
+                else:
+                    where.append('_%s._%s=%s'%(cn, k, a))
+                    args.append(v)
+
+        # don't match retired nodes
+        where.append('_%s.__retired__ <> 1'%cn)
+
+        # add results of full text search
+        if search_matches is not None:
+            v = search_matches.keys()
+            s = ','.join([a for x in v])
+            where.append('_%s.id in (%s)'%(cn, s))
+            args = args + v
+
+        # sanity check: sorting *and* grouping on the same property?
+        if group[1] == sort[1]:
+            sort = (None, None)
+
+        # "grouping" is just the first-order sorting in the SQL fetch
+        orderby = []
+        ordercols = []
+        mlsort = []
+        rhsnum = 0
+        for sortby in group, sort:
+            sdir, prop = sortby
+            if sdir and prop:
+                if isinstance(props[prop], Multilink):
+                    mlsort.append(sortby)
+                    continue
+                elif isinstance(props[prop], Interval):
+                    # use the int column for sorting
+                    o = '__'+prop+'_int__'
+                    ordercols.append(o)
+                elif isinstance(props[prop], Link):
+                    # determine whether the linked Class has an order property
+                    lcn = props[prop].classname
+                    link = self.db.classes[lcn]
+                    o = '_%s._%s'%(cn, prop)
+                    op = link.orderprop ()
+                    if op != 'id':
+                        tn = '_' + lcn
+                        rhs = 'rhs%s_'%rhsnum
+                        rhsnum += 1
+                        loj.append('LEFT OUTER JOIN %s as %s on %s=%s.id'%(
+                            tn, rhs, o, rhs))
+                        o = '%s._%s'%(rhs, op)
+                    ordercols.append(o)
+                elif prop == 'id':
+                    o = '_%s.id'%cn
+                else:
+                    o = '_%s._%s'%(cn, prop)
+                    ordercols.append(o)
+                if sdir == '-':
+                    o += ' desc'
+                orderby.append(o)
+
+        # construct the SQL
+        frum = ','.join(frum)
+        if where:
+            where = ' where ' + (' and '.join(where))
+        else:
+            where = ''
+        if mlfilt:
+            # we're joining tables on the id, so we will get dupes if we
+            # don't distinct()
+            cols = ['distinct(_%s.id)'%cn]
+        else:
+            cols = ['_%s.id'%cn]
+        if orderby:
+            cols = cols + ordercols
+            order = ' order by %s'%(','.join(orderby))
+        else:
+            order = ''
+        cols = ','.join(cols)
+        loj = ' '.join(loj)
+        sql = 'select %s from %s %s %s%s'%(cols, frum, loj, where, order)
+        args = tuple(args)
+        self.db.sql(sql, args)
+        l = self.db.cursor.fetchall()
+
+        # return the IDs (the first column)
+        # XXX numeric ids
+        l = [str(row[0]) for row in l]
+
+        if not mlsort:
+            if __debug__:
+                self.db.stats['filtering'] += (time.time() - start_t)
+            return l
+
+        # ergh. someone wants to sort by a multilink.
+        r = []
+        for id in l:
+            m = []
+            for ml in mlsort:
+                m.append(self.get(id, ml[1]))
+            r.append((id, m))
+        i = 0
+        for sortby in mlsort:
+            def sortfun(a, b, dir=sortby[i], i=i):
+                if dir == '-':
+                    return cmp(b[1][i], a[1][i])
+                else:
+                    return cmp(a[1][i], b[1][i])
+            r.sort(sortfun)
+            i += 1
+        r = [i[0] for i in r]
+
+        if __debug__:
+            self.db.stats['filtering'] += (time.time() - start_t)
+
+        return r
+
+class Class(MysqlClass, rdbms_common.Class):
+    pass
+class IssueClass(MysqlClass, rdbms_common.IssueClass):
+    pass
+class FileClass(MysqlClass, rdbms_common.FileClass):
+    pass
+
+# vim: set et sts=4 sw=4 :

Added: tracker/vendor/roundup/current/roundup/backends/back_postgresql.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/backends/back_postgresql.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,230 @@
+#$Id: back_postgresql.py,v 1.31 2006/01/20 02:27:12 richard Exp $
+#
+# Copyright (c) 2003 Martynas Sklyzmantas, Andrey Lebedev <andrey at micro.lt>
+#
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+'''Postgresql backend via psycopg for Roundup.'''
+__docformat__ = 'restructuredtext'
+
+import os, shutil, popen2, time
+import psycopg
+import logging
+
+from roundup import hyperdb, date
+from roundup.backends import rdbms_common
+
+def connection_dict(config, dbnamestr=None):
+    ''' read_default_group is MySQL-specific, ignore it '''
+    d = rdbms_common.connection_dict(config, dbnamestr)
+    if d.has_key('read_default_group'):
+        del d['read_default_group']
+    if d.has_key('read_default_file'):
+        del d['read_default_file']
+    return d
+
+def db_create(config):
+    """Clear all database contents and drop database itself"""
+    command = "CREATE DATABASE %s WITH ENCODING='UNICODE'"%config.RDBMS_NAME
+    logging.getLogger('hyperdb').info(command)
+    db_command(config, command)
+
+def db_nuke(config, fail_ok=0):
+    """Clear all database contents and drop database itself"""
+    command = 'DROP DATABASE %s'% config.RDBMS_NAME
+    logging.getLogger('hyperdb').info(command)
+    db_command(config, command)
+
+    if os.path.exists(config.DATABASE):
+        shutil.rmtree(config.DATABASE)
+
+def db_command(config, command):
+    '''Perform some sort of database-level command. Retry 10 times if we
+    fail by conflicting with another user.
+    '''
+    template1 = connection_dict(config)
+    template1['database'] = 'template1'
+    
+    try:
+        conn = psycopg.connect(**template1)
+    except psycopg.OperationalError, message:
+        raise hyperdb.DatabaseError, message
+    
+    conn.set_isolation_level(0)
+    cursor = conn.cursor()
+    try:
+        for n in range(10):
+            if pg_command(cursor, command):
+                return
+    finally:
+        conn.close()
+    raise RuntimeError, '10 attempts to create database failed'
+
+def pg_command(cursor, command):
+    '''Execute the postgresql command, which may be blocked by some other
+    user connecting to the database, and return a true value if it succeeds.
+    '''
+    try:
+        cursor.execute(command)
+    except psycopg.ProgrammingError, err:
+        response = str(err).split('\n')[0]
+        if response.find('FATAL') != -1:
+            raise RuntimeError, response
+        elif response.find('ERROR') != -1:
+            if response.find('is being accessed by other users') == -1:
+                raise RuntimeError, response
+            time.sleep(1)
+            return 0
+    return 1
+
+def db_exists(config):
+    """Check if database already exists"""
+    db = connection_dict(config, 'database')
+    try:
+        conn = psycopg.connect(**db)
+        conn.close()
+        return 1
+    except:
+        return 0
+
+class Database(rdbms_common.Database):
+    arg = '%s'
+
+    # used by some code to switch styles of query
+    implements_intersect = 1
+
+    def sql_open_connection(self):
+        db = connection_dict(self.config, 'database')
+        logging.getLogger('hyperdb').info('open database %r'%db['database'])
+        try:
+            conn = psycopg.connect(**db)
+        except psycopg.OperationalError, message:
+            raise hyperdb.DatabaseError, message
+
+        cursor = conn.cursor()
+
+        return (conn, cursor)
+
+    def open_connection(self):
+        if not db_exists(self.config):
+            db_create(self.config)
+
+        self.conn, self.cursor = self.sql_open_connection()
+
+        try:
+            self.load_dbschema()
+        except psycopg.ProgrammingError, message:
+            if str(message).find('"schema" does not exist') == -1:
+                raise
+            self.rollback()
+            self.init_dbschema()
+            self.sql("CREATE TABLE schema (schema TEXT)")
+            self.sql("CREATE TABLE dual (dummy integer)")
+            self.sql("insert into dual values (1)")
+            self.create_version_2_tables()
+
+    def create_version_2_tables(self):
+        # OTK store
+        self.sql('''CREATE TABLE otks (otk_key VARCHAR(255),
+            otk_value TEXT, otk_time REAL)''')
+        self.sql('CREATE INDEX otks_key_idx ON otks(otk_key)')
+
+        # Sessions store
+        self.sql('''CREATE TABLE sessions (
+            session_key VARCHAR(255), session_time REAL,
+            session_value TEXT)''')
+        self.sql('''CREATE INDEX sessions_key_idx ON
+            sessions(session_key)''')
+
+        # full-text indexing store
+        self.sql('CREATE SEQUENCE ___textids_ids')
+        self.sql('''CREATE TABLE __textids (
+            _textid integer primary key, _class VARCHAR(255),
+            _itemid VARCHAR(255), _prop VARCHAR(255))''')
+        self.sql('''CREATE TABLE __words (_word VARCHAR(30), 
+            _textid integer)''')
+        self.sql('CREATE INDEX words_word_idx ON __words(_word)')
+
+    def fix_version_2_tables(self):
+        # Convert journal date column to TIMESTAMP, params column to TEXT
+        self._convert_journal_tables()
+
+        # Convert all String properties to TEXT
+        self._convert_string_properties()
+
+        # convert session / OTK *_time columns to REAL
+        for name in ('otk', 'session'):
+            self.sql('drop index %ss_key_idx'%name)
+            self.sql('drop table %ss'%name)
+            self.sql('''CREATE TABLE %ss (%s_key VARCHAR(255),
+                %s_value VARCHAR(255), %s_time REAL)'''%(name, name, name,
+                name))
+            self.sql('CREATE INDEX %ss_key_idx ON %ss(%s_key)'%(name, name,
+                name))
+
+    def fix_version_3_tables(self):
+        rdbms_common.Database.fix_version_3_tables(self)
+        self.sql('''CREATE INDEX words_both_idx ON public.__words
+            USING btree (_word, _textid)''')
+
+    def add_actor_column(self):
+        # update existing tables to have the new actor column
+        tables = self.database_schema['tables']
+        for name in tables.keys():
+            self.sql('ALTER TABLE _%s add __actor VARCHAR(255)'%name)
+
+    def __repr__(self):
+        return '<roundpsycopgsql 0x%x>' % id(self)
+
+    def sql_stringquote(self, value):
+        ''' psycopg.QuotedString returns a "buffer" object with the
+            single-quotes around it... '''
+        return str(psycopg.QuotedString(str(value)))[1:-1]
+
+    def sql_index_exists(self, table_name, index_name):
+        sql = 'select count(*) from pg_indexes where ' \
+            'tablename=%s and indexname=%s'%(self.arg, self.arg)
+        self.sql(sql, (table_name, index_name))
+        return self.cursor.fetchone()[0]
+
+    def create_class_table(self, spec, create_sequence=1):
+        if create_sequence:
+            sql = 'CREATE SEQUENCE _%s_ids'%spec.classname
+            self.sql(sql)
+
+        return rdbms_common.Database.create_class_table(self, spec)
+
+    def drop_class_table(self, cn):
+        sql = 'drop table _%s'%cn
+        self.sql(sql)
+
+        sql = 'drop sequence _%s_ids'%cn
+        self.sql(sql)
+
+    def newid(self, classname):
+        sql = "select nextval('_%s_ids') from dual"%classname
+        self.sql(sql)
+        return self.cursor.fetchone()[0]
+
+    def setid(self, classname, setid):
+        sql = "select setval('_%s_ids', %s) from dual"%(classname, int(setid))
+        self.sql(sql)
+
+    def clear(self):
+        rdbms_common.Database.clear(self)
+
+        # reset the sequences
+        for cn in self.classes.keys():
+            self.cursor.execute('DROP SEQUENCE _%s_ids'%cn)
+            self.cursor.execute('CREATE SEQUENCE _%s_ids'%cn)
+
+
+class Class(rdbms_common.Class):
+    pass
+class IssueClass(rdbms_common.IssueClass):
+    pass
+class FileClass(rdbms_common.FileClass):
+    pass
+

Added: tracker/vendor/roundup/current/roundup/backends/back_sqlite.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/backends/back_sqlite.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,364 @@
+# $Id: back_sqlite.py,v 1.44 2006/04/27 04:59:37 richard Exp $
+'''Implements a backend for SQLite.
+
+See https://pysqlite.sourceforge.net/ for pysqlite info
+
+
+NOTE: we use the rdbms_common table creation methods which define datatypes
+for the columns, but sqlite IGNORES these specifications.
+'''
+__docformat__ = 'restructuredtext'
+
+import os, base64, marshal, shutil, time, logging
+
+from roundup import hyperdb, date, password
+from roundup.backends import rdbms_common
+import sqlite
+
+def db_exists(config):
+    return os.path.exists(os.path.join(config.DATABASE, 'db'))
+
+def db_nuke(config):
+    shutil.rmtree(config.DATABASE)
+
+class Database(rdbms_common.Database):
+    # char to use for positional arguments
+    arg = '%s'
+
+    # used by some code to switch styles of query
+    implements_intersect = 1
+
+    hyperdb_to_sql_datatypes = {
+        hyperdb.String : 'VARCHAR(255)',
+        hyperdb.Date   : 'VARCHAR(30)',
+        hyperdb.Link   : 'INTEGER',
+        hyperdb.Interval  : 'VARCHAR(255)',
+        hyperdb.Password  : 'VARCHAR(255)',
+        hyperdb.Boolean   : 'BOOLEAN',
+        hyperdb.Number    : 'REAL',
+    }
+    hyperdb_to_sql_value = {
+        hyperdb.String : str,
+        hyperdb.Date   : lambda x: x.serialise(),
+        hyperdb.Link   : int,
+        hyperdb.Interval  : str,
+        hyperdb.Password  : str,
+        hyperdb.Boolean   : int,
+        hyperdb.Number    : lambda x: x,
+        hyperdb.Multilink : lambda x: x,    # used in journal marshalling
+    }
+    sql_to_hyperdb_value = {
+        hyperdb.String : str,
+        hyperdb.Date   : lambda x: date.Date(str(x)),
+        hyperdb.Link   : str, # XXX numeric ids
+        hyperdb.Interval  : date.Interval,
+        hyperdb.Password  : lambda x: password.Password(encrypted=x),
+        hyperdb.Boolean   : int,
+        hyperdb.Number    : rdbms_common._num_cvt,
+        hyperdb.Multilink : lambda x: x,    # used in journal marshalling
+    }
+
+    def sqlite_busy_handler(self, data, table, count):
+        """invoked whenever SQLite tries to access a database that is locked"""
+        if count == 1:
+            # use a 30 second timeout (extraordinarily generous)
+            # for handling locked database
+            self._busy_handler_endtime = time.time() + 30
+        elif time.time() > self._busy_handler_endtime:
+            # timeout expired - no more retries
+            return 0
+        # sleep adaptively as retry count grows,
+        # starting from about half a second
+        time_to_sleep = 0.01 * (2 << min(5, count))
+        time.sleep(time_to_sleep)
+        return 1
+
+    def sql_open_connection(self):
+        '''Open a standard, non-autocommitting connection.
+
+        pysqlite will automatically BEGIN TRANSACTION for us.
+        '''
+        # make sure the database directory exists
+        # database itself will be created by sqlite if needed
+        if not os.path.isdir(self.config.DATABASE):
+            os.makedirs(self.config.DATABASE)
+
+        db = os.path.join(self.config.DATABASE, 'db')
+        logging.getLogger('hyperdb').info('open database %r'%db)
+        conn = sqlite.connect(db=db)
+        # set a 30 second timeout (extraordinarily generous) for handling
+        # locked database
+        conn.db.sqlite_busy_handler(self.sqlite_busy_handler)
+        cursor = conn.cursor()
+        return (conn, cursor)
+
+    def open_connection(self):
+        # ensure files are group readable and writable
+        os.umask(self.config.UMASK)
+
+        (self.conn, self.cursor) = self.sql_open_connection()
+
+        try:
+            self.load_dbschema()
+        except sqlite.DatabaseError, error:
+            if str(error) != 'no such table: schema':
+                raise
+            self.init_dbschema()
+            self.sql('create table schema (schema varchar)')
+            self.sql('create table ids (name varchar, num integer)')
+            self.sql('create index ids_name_idx on ids(name)')
+            self.create_version_2_tables()
+
+    def create_version_2_tables(self):
+        self.sql('create table otks (otk_key varchar, '
+            'otk_value varchar, otk_time integer)')
+        self.sql('create index otks_key_idx on otks(otk_key)')
+        self.sql('create table sessions (session_key varchar, '
+            'session_time integer, session_value varchar)')
+        self.sql('create index sessions_key_idx on '
+                'sessions(session_key)')
+
+        # full-text indexing store
+        self.sql('CREATE TABLE __textids (_class varchar, '
+            '_itemid varchar, _prop varchar, _textid integer primary key) ')
+        self.sql('CREATE TABLE __words (_word varchar, '
+            '_textid integer)')
+        self.sql('CREATE INDEX words_word_ids ON __words(_word)')
+        sql = 'insert into ids (name, num) values (%s,%s)'%(self.arg, self.arg)
+        self.sql(sql, ('__textids', 1))
+
+    def add_new_columns_v2(self):
+        # update existing tables to have the new actor column
+        tables = self.database_schema['tables']
+        for classname, spec in self.classes.items():
+            if tables.has_key(classname):
+                dbspec = tables[classname]
+                self.update_class(spec, dbspec, force=1, adding_v2=1)
+                # we've updated - don't try again
+                tables[classname] = spec.schema()
+
+    def fix_version_3_tables(self):
+        # NOOP - no restriction on column length here
+        pass
+
+    def update_class(self, spec, old_spec, force=0, adding_v2=0):
+        ''' Determine the differences between the current spec and the
+            database version of the spec, and update where necessary.
+
+            If 'force' is true, update the database anyway.
+
+            SQLite doesn't have ALTER TABLE, so we have to copy and
+            regenerate the tables with the new schema.
+        '''
+        new_has = spec.properties.has_key
+        new_spec = spec.schema()
+        new_spec[1].sort()
+        old_spec[1].sort()
+        if not force and new_spec == old_spec:
+            # no changes
+            return 0
+
+        logging.getLogger('hyperdb').info('update_class %s'%spec.classname)
+
+        # detect multilinks that have been removed, and drop their table
+        old_has = {}
+        for name, prop in old_spec[1]:
+            old_has[name] = 1
+            if new_has(name) or not isinstance(prop, hyperdb.Multilink):
+                continue
+            # it's a multilink, and it's been removed - drop the old
+            # table. First drop indexes.
+            self.drop_multilink_table_indexes(spec.classname, name)
+            sql = 'drop table %s_%s'%(spec.classname, prop)
+            self.sql(sql)
+        old_has = old_has.has_key
+
+        # now figure how we populate the new table
+        if adding_v2:
+            fetch = ['_activity', '_creation', '_creator']
+        else:
+            fetch = ['_actor', '_activity', '_creation', '_creator']
+        properties = spec.getprops()
+        for propname,x in new_spec[1]:
+            prop = properties[propname]
+            if isinstance(prop, hyperdb.Multilink):
+                if not old_has(propname):
+                    # we need to create the new table
+                    self.create_multilink_table(spec, propname)
+                elif force:
+                    tn = '%s_%s'%(spec.classname, propname)
+                    # grabe the current values
+                    sql = 'select linkid, nodeid from %s'%tn
+                    self.sql(sql)
+                    rows = self.cursor.fetchall()
+
+                    # drop the old table
+                    self.drop_multilink_table_indexes(spec.classname, propname)
+                    sql = 'drop table %s'%tn
+                    self.sql(sql)
+
+                    # re-create and populate the new table
+                    self.create_multilink_table(spec, propname)
+                    sql = '''insert into %s (linkid, nodeid) values
+                        (%s, %s)'''%(tn, self.arg, self.arg)
+                    for linkid, nodeid in rows:
+                        self.sql(sql, (int(linkid), int(nodeid)))
+            elif old_has(propname):
+                # we copy this col over from the old table
+                fetch.append('_'+propname)
+
+        # select the data out of the old table
+        fetch.append('id')
+        fetch.append('__retired__')
+        fetchcols = ','.join(fetch)
+        cn = spec.classname
+        sql = 'select %s from _%s'%(fetchcols, cn)
+        self.sql(sql)
+        olddata = self.cursor.fetchall()
+
+        # TODO: update all the other index dropping code
+        self.drop_class_table_indexes(cn, old_spec[0])
+
+        # drop the old table
+        self.sql('drop table _%s'%cn)
+
+        # create the new table
+        self.create_class_table(spec)
+
+        if olddata:
+            inscols = ['id', '_actor', '_activity', '_creation', '_creator']
+            for propname,x in new_spec[1]:
+                prop = properties[propname]
+                if isinstance(prop, hyperdb.Multilink):
+                    continue
+                elif isinstance(prop, hyperdb.Interval):
+                    inscols.append('_'+propname)
+                    inscols.append('__'+propname+'_int__')
+                elif old_has(propname):
+                    # we copy this col over from the old table
+                    inscols.append('_'+propname)
+
+            # do the insert of the old data - the new columns will have
+            # NULL values
+            args = ','.join([self.arg for x in inscols])
+            cols = ','.join(inscols)
+            sql = 'insert into _%s (%s) values (%s)'%(cn, cols, args)
+            for entry in olddata:
+                d = []
+                for name in inscols:
+                    # generate the new value for the Interval int column
+                    if name.endswith('_int__'):
+                        name = name[2:-6]
+                        if entry.has_key(name):
+                            v = hyperdb.Interval(entry[name]).as_seconds()
+                        else:
+                            v = None
+                    elif entry.has_key(name):
+                        v = entry[name]
+                    else:
+                        v = None
+                    d.append(v)
+                self.sql(sql, tuple(d))
+
+        return 1
+
+    def sql_close(self):
+        ''' Squash any error caused by us already having closed the
+            connection.
+        '''
+        try:
+            self.conn.close()
+        except sqlite.ProgrammingError, value:
+            if str(value) != 'close failed - Connection is closed.':
+                raise
+
+    def sql_rollback(self):
+        ''' Squash any error caused by us having closed the connection (and
+            therefore not having anything to roll back)
+        '''
+        try:
+            self.conn.rollback()
+        except sqlite.ProgrammingError, value:
+            if str(value) != 'rollback failed - Connection is closed.':
+                raise
+
+    def __repr__(self):
+        return '<roundlite 0x%x>'%id(self)
+
+    def sql_commit(self):
+        ''' Actually commit to the database.
+
+            Ignore errors if there's nothing to commit.
+        '''
+        try:
+            self.conn.commit()
+        except sqlite.DatabaseError, error:
+            if str(error) != 'cannot commit - no transaction is active':
+                raise
+        # open a new cursor for subsequent work
+        self.cursor = self.conn.cursor()
+
+    def sql_index_exists(self, table_name, index_name):
+        self.sql('pragma index_list(%s)'%table_name)
+        for entry in self.cursor.fetchall():
+            if entry[1] == index_name:
+                return 1
+        return 0
+
+    # old-skool id generation
+    def newid(self, classname):
+        ''' Generate a new id for the given class
+        '''
+        # get the next ID
+        sql = 'select num from ids where name=%s'%self.arg
+        self.sql(sql, (classname, ))
+        newid = int(self.cursor.fetchone()[0])
+
+        # update the counter
+        sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
+        vals = (int(newid)+1, classname)
+        self.sql(sql, vals)
+
+        # return as string
+        return str(newid)
+
+    def setid(self, classname, setid):
+        ''' Set the id counter: used during import of database
+
+        We add one to make it behave like the sequences in postgres.
+        '''
+        sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
+        vals = (int(setid)+1, classname)
+        self.sql(sql, vals)
+
+    def clear(self):
+        rdbms_common.Database.clear(self)
+        # set the id counters to 0 (setid adds one) so we start at 1
+        for cn in self.classes.keys():
+            self.setid(cn, 0)
+
+    def create_class(self, spec):
+        rdbms_common.Database.create_class(self, spec)
+        sql = 'insert into ids (name, num) values (%s, %s)'
+        vals = (spec.classname, 1)
+        self.sql(sql, vals)
+
+class sqliteClass:
+    def filter(self, search_matches, filterspec, sort=(None,None),
+            group=(None,None)):
+        ''' If there's NO matches to a fetch, sqlite returns NULL
+            instead of nothing
+        '''
+        return filter(None, rdbms_common.Class.filter(self, search_matches,
+            filterspec, sort=sort, group=group))
+
+class Class(sqliteClass, rdbms_common.Class):
+    pass
+
+class IssueClass(sqliteClass, rdbms_common.IssueClass):
+    pass
+
+class FileClass(sqliteClass, rdbms_common.FileClass):
+    pass
+
+# vim: set et sts=4 sw=4 :

Added: tracker/vendor/roundup/current/roundup/backends/back_tsearch2.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/backends/back_tsearch2.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,178 @@
+#$Id: back_tsearch2.py,v 1.9 2005/01/08 16:16:59 jlgijsbers Exp $
+
+# Note: this backend is EXPERIMENTAL. Do not use if you value your data.
+import re
+
+import psycopg
+
+from roundup import hyperdb
+from roundup.support import ensureParentsExist
+from roundup.backends import back_postgresql, tsearch2_setup, indexer_rdbms
+from roundup.backends.back_postgresql import db_create, db_nuke, db_command
+from roundup.backends.back_postgresql import pg_command, db_exists, Class, IssueClass, FileClass
+from roundup.backends.indexer_common import _isLink, Indexer
+
+# XXX: Should probably be on the Class class.
+def _indexedProps(spec):
+    """Get a list of properties to be indexed on 'spec'."""
+    return [prop for prop, propclass in spec.getprops().items()
+            if isinstance(propclass, hyperdb.String) and propclass.indexme]
+
+def _getQueryDict(spec):
+    """Get a convenience dictionary for creating tsearch2 indexes."""
+    query_dict = {'classname': spec.classname,
+                  'indexedColumns': ['_' + prop for prop in _indexedProps(spec)]}
+    query_dict['tablename'] = "_%(classname)s" % query_dict
+    query_dict['triggername'] = "%(tablename)s_tsvectorupdate" % query_dict
+    return query_dict
+
+class Database(back_postgresql.Database):
+    def __init__(self, config, journaltag=None):
+        back_postgresql.Database.__init__(self, config, journaltag)
+        self.indexer = Indexer(self)
+    
+    def create_version_2_tables(self):
+        back_postgresql.Database.create_version_2_tables(self)
+        tsearch2_setup.setup(self.cursor)    
+
+    def create_class_table_indexes(self, spec):
+        back_postgresql.Database.create_class_table_indexes(self, spec)
+        self.cursor.execute("""CREATE INDEX _%(classname)s_idxFTI_idx
+                               ON %(tablename)s USING gist(idxFTI);""" %
+                            _getQueryDict(spec))
+
+        self.create_tsearch2_trigger(spec)
+
+    def create_tsearch2_trigger(self, spec):
+        d = _getQueryDict(spec)
+        if d['indexedColumns']:
+            
+            d['joined'] = " || ' ' ||".join(d['indexedColumns'])
+            query = """UPDATE %(tablename)s
+                       SET idxFTI = to_tsvector('default', %(joined)s)""" % d
+            self.cursor.execute(query)
+
+            d['joined'] = ", ".join(d['indexedColumns']) 
+            query = """CREATE TRIGGER %(triggername)s
+                       BEFORE UPDATE OR INSERT ON %(tablename)s
+                       FOR EACH ROW EXECUTE PROCEDURE
+                       tsearch2(idxFTI, %(joined)s);""" % d
+            self.cursor.execute(query)
+
+    def drop_tsearch2_trigger(self, spec):
+        # Check whether the trigger exists before trying to drop it.
+        query_dict = _getQueryDict(spec)
+        self.sql("""SELECT tgname FROM pg_catalog.pg_trigger
+                    WHERE tgname = '%(triggername)s'""" % query_dict)
+        if self.cursor.fetchall():
+            self.sql("""DROP TRIGGER %(triggername)s ON %(tablename)s""" %
+                     query_dict)
+
+    def update_class(self, spec, old_spec, force=0):
+        result = back_postgresql.Database.update_class(self, spec, old_spec, force)
+
+        # Drop trigger...
+        self.drop_tsearch2_trigger(spec)
+
+        # and recreate if necessary.
+        self.create_tsearch2_trigger(spec)
+
+        return result
+
+    def determine_all_columns(self, spec):
+        cols, mls = back_postgresql.Database.determine_all_columns(self, spec)
+        cols.append(('idxFTI', 'tsvector'))
+        return cols, mls
+        
+class Indexer(Indexer):
+    def __init__(self, db):
+        self.db = db
+
+    # This indexer never needs to reindex.
+    def should_reindex(self):
+        return 0
+
+    def getHits(self, search_terms, klass):
+        return self.find(search_terms, klass)    
+    
+    def find(self, search_terms, klass):
+        if not search_terms:
+            return None
+
+        hits = self.tsearchQuery(klass.classname, search_terms)
+        designator_propname = {}
+
+        for nm, propclass in klass.getprops().items():
+            if _isLink(propclass):
+                hits.extend(self.tsearchQuery(propclass.classname, search_terms))
+
+        return hits
+
+    def tsearchQuery(self, classname, search_terms):
+        query = """SELECT id FROM _%(classname)s
+                   WHERE idxFTI @@ to_tsquery('default', '%(terms)s')"""                    
+        
+        query = query % {'classname': classname,
+                         'terms': ' & '.join(search_terms)}
+        self.db.cursor.execute(query)
+        klass = self.db.getclass(classname)
+        nodeids = [str(row[0]) for row in self.db.cursor.fetchall()]
+
+        # filter out files without text/plain mime type
+        # XXX: files without text/plain shouldn't be indexed at all, we
+        # should take care of this in the trigger
+        if klass.getprops().has_key('type'):
+            nodeids = [nodeid for nodeid in nodeids
+                       if klass.get(nodeid, 'type') == 'text/plain']
+
+        # XXX: We haven't implemented property-level search, so I'm just faking
+        # it here with a property named 'XXX'. We still need to fix the other
+        # backends and indexer_common.Indexer.search to only want to unpack two
+        # values.
+        return [(classname, nodeid, 'XXX') for nodeid in nodeids]
+
+    # These only exist to satisfy the interface that's expected from indexers.
+    def force_reindex(self):
+        pass
+
+    def add_text(self, identifier, text, mime_type=None):
+        pass
+
+    def close(self):
+        pass
+
+class FileClass(hyperdb.FileClass, Class):
+    '''This class defines a large chunk of data. To support this, it has a
+       mandatory String property "content" which is typically saved off
+       externally to the hyperdb.
+
+       However, this implementation just stores it in the hyperdb.
+    '''
+    def __init__(self, db, classname, **properties):
+        '''The newly-created class automatically includes the "content" property.,
+        '''
+        properties['content'] = hyperdb.String(indexme='yes')
+        Class.__init__(self, db, classname, **properties)
+
+    default_mime_type = 'text/plain'
+    def create(self, **propvalues):
+        # figure the mime type
+        if self.getprops().has_key('type') and not propvalues.get('type'):
+            propvalues['type'] = self.default_mime_type
+        return Class.create(self, **propvalues)
+
+    def export_files(self, dirname, nodeid):
+        dest = self.exportFilename(dirname, nodeid)
+        ensureParentsExist(dest)
+        fp = open(dest, "w")
+        fp.write(self.get(nodeid, "content", default=''))
+        fp.close()
+
+    def import_files(self, dirname, nodeid):
+        source = self.exportFilename(dirname, nodeid)
+
+        fp = open(source, "r")
+        # Use Database.setnode instead of self.set or self.set_inner here, as
+        # Database.setnode doesn't update the "activity" or "actor" properties.
+        self.db.setnode(self.classname, nodeid, values={'content': fp.read()})
+        fp.close()

Added: tracker/vendor/roundup/current/roundup/backends/blobfiles.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/backends/blobfiles.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,158 @@
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+# 
+#$Id: blobfiles.py,v 1.19 2005/06/08 03:35:18 anthonybaxter Exp $
+'''This module exports file storage for roundup backends.
+Files are stored into a directory hierarchy.
+'''
+__docformat__ = 'restructuredtext'
+
+import os
+
+def files_in_dir(dir):
+    if not os.path.exists(dir):
+        return 0
+    num_files = 0
+    for dir_entry in os.listdir(dir):
+        full_filename = os.path.join(dir,dir_entry)
+        if os.path.isfile(full_filename):
+            num_files = num_files + 1
+        elif os.path.isdir(full_filename):
+            num_files = num_files + files_in_dir(full_filename)
+    return num_files
+
+class FileStorage:
+    """Store files in some directory structure"""    
+    def subdirFilename(self, classname, nodeid, property=None):
+        """Determine what the filename and subdir for nodeid + classname is."""
+        if property:
+            name = '%s%s.%s'%(classname, nodeid, property)
+        else:
+            # roundupdb.FileClass never specified the property name, so don't 
+            # include it
+            name = '%s%s'%(classname, nodeid)
+        
+        # have a separate subdir for every thousand messages
+        subdir = str(int(nodeid) / 1000)
+        return os.path.join(subdir, name)
+    
+    def filename(self, classname, nodeid, property=None, create=0):
+        '''Determine what the filename for the given node and optionally 
+        property is.
+
+        Try a variety of different filenames - the file could be in the
+        usual place, or it could be in a temp file pre-commit *or* it
+        could be in an old-style, backwards-compatible flat directory.
+        '''
+        filename  = os.path.join(self.dir, 'files', classname,
+                                 self.subdirFilename(classname, nodeid, property))
+        if create or os.path.exists(filename):
+            return filename
+
+        # try .tmp
+        filename = filename + '.tmp'
+        if os.path.exists(filename):
+            return filename
+
+        # ok, try flat (very old-style)
+        if property:
+            filename = os.path.join(self.dir, 'files', '%s%s.%s'%(classname,
+                nodeid, property))
+        else:
+            filename = os.path.join(self.dir, 'files', '%s%s'%(classname,
+                nodeid))
+        if os.path.exists(filename):
+            return filename
+
+        # file just ain't there
+        raise IOError('content file for %s not found'%filename)
+
+    def storefile(self, classname, nodeid, property, content):
+        '''Store the content of the file in the database. The property may be
+           None, in which case the filename does not indicate which property
+           is being saved.
+        '''
+        # determine the name of the file to write to
+        name = self.filename(classname, nodeid, property, create=1)
+
+        # make sure the file storage dir exists
+        if not os.path.exists(os.path.dirname(name)):
+            os.makedirs(os.path.dirname(name))
+
+        # save to a temp file
+        name = name + '.tmp'
+
+        # make sure we don't register the rename action more than once
+        if not os.path.exists(name):
+            # save off the rename action
+            self.transactions.append((self.doStoreFile, (classname, nodeid,
+                property)))
+        open(name, 'wb').write(content)
+
+    def getfile(self, classname, nodeid, property):
+        '''Get the content of the file in the database.
+        '''
+        filename = self.filename(classname, nodeid, property)
+
+        f = open(filename, 'rb')
+        try:
+            # snarf the contents and make sure we close the file
+            return f.read()
+        finally:
+            f.close()
+
+    def numfiles(self):
+        '''Get number of files in storage, even across subdirectories.
+        '''
+        files_dir = os.path.join(self.dir, 'files')
+        return files_in_dir(files_dir)
+
+    def doStoreFile(self, classname, nodeid, property, **databases):
+        '''Store the file as part of a transaction commit.
+        '''
+        # determine the name of the file to write to
+        name = self.filename(classname, nodeid, property)
+
+        # the file is currently ".tmp" - move it to its real name to commit
+        if name.endswith('.tmp'):
+            # creation
+            dstname = os.path.splitext(name)[0]
+        else:
+            # edit operation
+            dstname = name
+            name = name + '.tmp'
+
+        # content is being updated (and some platforms, eg. win32, won't
+        # let us rename over the top of the old file)
+        if os.path.exists(dstname):
+            os.remove(dstname)
+
+        os.rename(name, dstname)
+
+        # return the classname, nodeid so we reindex this content
+        return (classname, nodeid)
+
+    def rollbackStoreFile(self, classname, nodeid, property, **databases):
+        '''Remove the temp file as a part of a rollback
+        '''
+        # determine the name of the file to delete
+        name = self.filename(classname, nodeid, property)
+        if not name.endswith('.tmp'):
+            name += '.tmp'
+        os.remove(name)
+
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/roundup/backends/indexer_common.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/backends/indexer_common.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,92 @@
+#$Id: indexer_common.py,v 1.6 2006/04/27 05:48:26 richard Exp $
+import re, sets
+
+from roundup import hyperdb
+
+STOPWORDS = [
+    "A", "AND", "ARE", "AS", "AT", "BE", "BUT", "BY",
+    "FOR", "IF", "IN", "INTO", "IS", "IT",
+    "NO", "NOT", "OF", "ON", "OR", "SUCH",
+    "THAT", "THE", "THEIR", "THEN", "THERE", "THESE",
+    "THEY", "THIS", "TO", "WAS", "WILL", "WITH" 
+]
+
+def _isLink(propclass):
+    return (isinstance(propclass, hyperdb.Link) or
+            isinstance(propclass, hyperdb.Multilink))
+
+class Indexer:
+    def __init__(self, db):
+        self.stopwords = sets.Set(STOPWORDS)
+        for word in db.config[('main', 'indexer_stopwords')]:
+            self.stopwords.add(word)
+
+    def is_stopword(self, word):
+        return word in self.stopwords
+
+    def getHits(self, search_terms, klass):
+        return self.find(search_terms)
+    
+    def search(self, search_terms, klass, ignore={}):
+        '''Display search results looking for [search, terms] associated
+        with the hyperdb Class "klass". Ignore hits on {class: property}.
+
+        "dre" is a helper, not an argument.
+        '''
+        # do the index lookup
+        hits = self.getHits(search_terms, klass)
+        if not hits:
+            return {}
+
+        designator_propname = {}
+        for nm, propclass in klass.getprops().items():
+            if _isLink(propclass):
+                designator_propname[propclass.classname] = nm
+
+        # build a dictionary of nodes and their associated messages
+        # and files
+        nodeids = {}      # this is the answer
+        propspec = {}     # used to do the klass.find
+        for propname in designator_propname.values():
+            propspec[propname] = {}   # used as a set (value doesn't matter)
+        for classname, nodeid, property in hits:
+            # skip this result if we don't care about this class/property
+            if ignore.has_key((classname, property)):
+                continue
+
+            # if it's a property on klass, it's easy
+            if classname == klass.classname:
+                if not nodeids.has_key(nodeid):
+                    nodeids[nodeid] = {}
+                continue
+
+            # make sure the class is a linked one, otherwise ignore
+            if not designator_propname.has_key(classname):
+                continue
+
+            # it's a linked class - set up to do the klass.find
+            linkprop = designator_propname[classname]   # eg, msg -> messages
+            propspec[linkprop][nodeid] = 1
+
+        # retain only the meaningful entries
+        for propname, idset in propspec.items():
+            if not idset:
+                del propspec[propname]
+        
+        # klass.find tells me the klass nodeids the linked nodes relate to
+        for resid in klass.find(**propspec):
+            resid = str(resid)
+            if not nodeids.has_key(id):
+                nodeids[resid] = {}
+            node_dict = nodeids[resid]
+            # now figure out where it came from
+            for linkprop in propspec.keys():
+                for nodeid in klass.get(resid, linkprop):
+                    if propspec[linkprop].has_key(nodeid):
+                        # OK, this node[propname] has a winner
+                        if not node_dict.has_key(linkprop):
+                            node_dict[linkprop] = [nodeid]
+                        else:
+                            node_dict[linkprop].append(nodeid)
+        return nodeids
+

Added: tracker/vendor/roundup/current/roundup/backends/indexer_dbm.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/backends/indexer_dbm.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,292 @@
+#
+# This module is derived from the module described at:
+#   http://gnosis.cx/publish/programming/charming_python_15.txt
+# 
+# Author: David Mertz (mertz at gnosis.cx)
+# Thanks to: Pat Knight (p.knight at ktgroup.co.uk)
+#            Gregory Popovitch (greg at gpy.com)
+# 
+# The original module was released under this license, and remains under
+# it:
+#
+#     This file is released to the public domain.  I (dqm) would
+#     appreciate it if you choose to keep derived works under terms
+#     that promote freedom, but obviously am giving up any rights
+#     to compel such.
+# 
+#$Id: indexer_dbm.py,v 1.9 2006/04/27 05:48:26 richard Exp $
+'''This module provides an indexer class, RoundupIndexer, that stores text
+indices in a roundup instance.  This class makes searching the content of
+messages, string properties and text files possible.
+'''
+__docformat__ = 'restructuredtext'
+
+import os, shutil, re, mimetypes, marshal, zlib, errno
+from roundup.hyperdb import Link, Multilink
+from roundup.backends.indexer_common import Indexer as IndexerBase
+
+class Indexer(IndexerBase):
+    '''Indexes information from roundup's hyperdb to allow efficient
+    searching.
+
+    Three structures are created by the indexer::
+
+          files   {identifier: (fileid, wordcount)}
+          words   {word: {fileid: count}}
+          fileids {fileid: identifier}
+
+    where identifier is (classname, nodeid, propertyname)
+    '''
+    def __init__(self, db):
+        IndexerBase.__init__(self, db)
+        self.indexdb_path = os.path.join(db.config.DATABASE, 'indexes')
+        self.indexdb = os.path.join(self.indexdb_path, 'index.db')
+        self.reindex = 0
+        self.quiet = 9
+        self.changed = 0
+
+        # see if we need to reindex because of a change in code
+        version = os.path.join(self.indexdb_path, 'version')
+        if (not os.path.exists(self.indexdb_path) or
+                not os.path.exists(version)):
+            # for now the file itself is a flag
+            self.force_reindex()
+        elif os.path.exists(version):
+            version = open(version).read()
+            # check the value and reindex if it's not the latest
+            if version.strip() != '1':
+                self.force_reindex()
+
+    def force_reindex(self):
+        '''Force a reindex condition
+        '''
+        if os.path.exists(self.indexdb_path):
+            shutil.rmtree(self.indexdb_path)
+        os.makedirs(self.indexdb_path)
+        os.chmod(self.indexdb_path, 0775)
+        open(os.path.join(self.indexdb_path, 'version'), 'w').write('1\n')
+        self.reindex = 1
+        self.changed = 1
+
+    def should_reindex(self):
+        '''Should we reindex?
+        '''
+        return self.reindex
+
+    def add_text(self, identifier, text, mime_type='text/plain'):
+        '''Add some text associated with the (classname, nodeid, property)
+        identifier.
+        '''
+        # make sure the index is loaded
+        self.load_index()
+
+        # remove old entries for this identifier
+        if self.files.has_key(identifier):
+            self.purge_entry(identifier)
+
+        # split into words
+        words = self.splitter(text, mime_type)
+
+        # Find new file index, and assign it to identifier
+        # (_TOP uses trick of negative to avoid conflict with file index)
+        self.files['_TOP'] = (self.files['_TOP'][0]-1, None)
+        file_index = abs(self.files['_TOP'][0])
+        self.files[identifier] = (file_index, len(words))
+        self.fileids[file_index] = identifier
+
+        # find the unique words
+        filedict = {}
+        for word in words:
+            if self.is_stopword(word):
+                continue
+            if filedict.has_key(word):
+                filedict[word] = filedict[word]+1
+            else:
+                filedict[word] = 1
+
+        # now add to the totals
+        for word in filedict.keys():
+            # each word has a dict of {identifier: count}
+            if self.words.has_key(word):
+                entry = self.words[word]
+            else:
+                # new word
+                entry = {}
+                self.words[word] = entry
+
+            # make a reference to the file for this word
+            entry[file_index] = filedict[word]
+
+        # save needed
+        self.changed = 1
+
+    def splitter(self, text, ftype):
+        '''Split the contents of a text string into a list of 'words'
+        '''
+        if ftype == 'text/plain':
+            words = self.text_splitter(text)
+        else:
+            return []
+        return words
+
+    def text_splitter(self, text):
+        """Split text/plain string into a list of words
+        """
+        # case insensitive
+        text = str(text).upper()
+
+        # Split the raw text, losing anything longer than 25 characters
+        # since that'll be gibberish (encoded text or somesuch) or shorter
+        # than 3 characters since those short words appear all over the
+        # place
+        return re.findall(r'\b\w{2,25}\b', text)
+
+    # we override this to ignore not 2 < word < 25 and also to fix a bug -
+    # the (fail) case.
+    def find(self, wordlist):
+        '''Locate files that match ALL the words in wordlist
+        '''
+        if not hasattr(self, 'words'):
+            self.load_index()
+        self.load_index(wordlist=wordlist)
+        entries = {}
+        hits = None
+        for word in wordlist:
+            if not 2 < len(word) < 25:
+                # word outside the bounds of what we index - ignore
+                continue
+            word = word.upper()
+            entry = self.words.get(word)    # For each word, get index
+            entries[word] = entry           #   of matching files
+            if not entry:                   # Nothing for this one word (fail)
+                return {}
+            if hits is None:
+                hits = {}
+                for k in entry.keys():
+                    if not self.fileids.has_key(k):
+                        raise ValueError, 'Index is corrupted: re-generate it'
+                    hits[k] = self.fileids[k]
+            else:
+                # Eliminate hits for every non-match
+                for fileid in hits.keys():
+                    if not entry.has_key(fileid):
+                        del hits[fileid]
+        if hits is None:
+            return {}
+        return hits.values()
+
+    segments = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ#_-!"
+    def load_index(self, reload=0, wordlist=None):
+        # Unless reload is indicated, do not load twice
+        if self.index_loaded() and not reload:
+            return 0
+
+        # Ok, now let's actually load it
+        db = {'WORDS': {}, 'FILES': {'_TOP':(0,None)}, 'FILEIDS': {}}
+
+        # Identify the relevant word-dictionary segments
+        if not wordlist:
+            segments = self.segments
+        else:
+            segments = ['-','#']
+            for word in wordlist:
+                segments.append(word[0].upper())
+
+        # Load the segments
+        for segment in segments:
+            try:
+                f = open(self.indexdb + segment, 'rb')
+            except IOError, error:
+                # probably just nonexistent segment index file
+                if error.errno != errno.ENOENT: raise
+            else:
+                pickle_str = zlib.decompress(f.read())
+                f.close()
+                dbslice = marshal.loads(pickle_str)
+                if dbslice.get('WORDS'):
+                    # if it has some words, add them
+                    for word, entry in dbslice['WORDS'].items():
+                        db['WORDS'][word] = entry
+                if dbslice.get('FILES'):
+                    # if it has some files, add them
+                    db['FILES'] = dbslice['FILES']
+                if dbslice.get('FILEIDS'):
+                    # if it has fileids, add them
+                    db['FILEIDS'] = dbslice['FILEIDS']
+
+        self.words = db['WORDS']
+        self.files = db['FILES']
+        self.fileids = db['FILEIDS']
+        self.changed = 0
+
+    def save_index(self):
+        # only save if the index is loaded and changed
+        if not self.index_loaded() or not self.changed:
+            return
+
+        # brutal space saver... delete all the small segments
+        for segment in self.segments:
+            try:
+                os.remove(self.indexdb + segment)
+            except OSError, error:
+                # probably just nonexistent segment index file
+                if error.errno != errno.ENOENT: raise
+
+        # First write the much simpler filename/fileid dictionaries
+        dbfil = {'WORDS':None, 'FILES':self.files, 'FILEIDS':self.fileids}
+        open(self.indexdb+'-','wb').write(zlib.compress(marshal.dumps(dbfil)))
+
+        # The hard part is splitting the word dictionary up, of course
+        letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ#_"
+        segdicts = {}                           # Need batch of empty dicts
+        for segment in letters:
+            segdicts[segment] = {}
+        for word, entry in self.words.items():  # Split into segment dicts
+            initchar = word[0].upper()
+            segdicts[initchar][word] = entry
+
+        # save
+        for initchar in letters:
+            db = {'WORDS':segdicts[initchar], 'FILES':None, 'FILEIDS':None}
+            pickle_str = marshal.dumps(db)
+            filename = self.indexdb + initchar
+            pickle_fh = open(filename, 'wb')
+            pickle_fh.write(zlib.compress(pickle_str))
+            os.chmod(filename, 0664)
+
+        # save done
+        self.changed = 0
+
+    def purge_entry(self, identifier):
+        '''Remove a file from file index and word index
+        '''
+        self.load_index()
+
+        if not self.files.has_key(identifier):
+            return
+
+        file_index = self.files[identifier][0]
+        del self.files[identifier]
+        del self.fileids[file_index]
+
+        # The much harder part, cleanup the word index
+        for key, occurs in self.words.items():
+            if occurs.has_key(file_index):
+                del occurs[file_index]
+
+        # save needed
+        self.changed = 1
+
+    def index_loaded(self):
+        return (hasattr(self,'fileids') and hasattr(self,'files') and
+            hasattr(self,'words'))
+
+    def rollback(self):
+        ''' load last saved index info. '''
+        self.load_index(reload=1)
+
+    def close(self):
+        pass
+
+
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/roundup/backends/indexer_rdbms.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/backends/indexer_rdbms.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,139 @@
+#$Id: indexer_rdbms.py,v 1.12 2006/02/06 21:00:47 richard Exp $
+''' This implements the full-text indexer over two RDBMS tables. The first
+is a mapping of words to occurance IDs. The second maps the IDs to (Class,
+propname, itemid) instances.
+'''
+import re
+
+from roundup.backends.indexer_common import Indexer as IndexerBase
+
+class Indexer(IndexerBase):
+    def __init__(self, db):
+        IndexerBase.__init__(self, db)
+        self.db = db
+        self.reindex = 0
+
+    def close(self):
+        '''close the indexing database'''
+        # just nuke the circular reference
+        self.db = None
+
+    def save_index(self):
+        '''Save the changes to the index.'''
+        # not necessary - the RDBMS connection will handle this for us
+        pass
+
+    def force_reindex(self):
+        '''Force a reindexing of the database.  This essentially
+        empties the tables ids and index and sets a flag so
+        that the databases are reindexed'''
+        self.reindex = 1
+
+    def should_reindex(self):
+        '''returns True if the indexes need to be rebuilt'''
+        return self.reindex
+
+    def add_text(self, identifier, text, mime_type='text/plain'):
+        ''' "identifier" is  (classname, itemid, property) '''
+        if mime_type != 'text/plain':
+            return
+
+        # first, find the id of the (classname, itemid, property)
+        a = self.db.arg
+        sql = 'select _textid from __textids where _class=%s and '\
+            '_itemid=%s and _prop=%s'%(a, a, a)
+        self.db.cursor.execute(sql, identifier)
+        r = self.db.cursor.fetchone()
+        if not r:
+            id = self.db.newid('__textids')
+            sql = 'insert into __textids (_textid, _class, _itemid, _prop)'\
+                ' values (%s, %s, %s, %s)'%(a, a, a, a)
+            self.db.cursor.execute(sql, (id, ) + identifier)
+            self.db.cursor.execute('select max(_textid) from __textids')
+            id = self.db.cursor.fetchone()[0]
+        else:
+            id = int(r[0])
+            # clear out any existing indexed values
+            sql = 'delete from __words where _textid=%s'%a
+            self.db.cursor.execute(sql, (id, ))
+
+        # ok, find all the words in the text
+        text = unicode(text, "utf-8", "replace").upper()
+        wordlist = [w.encode("utf-8", "replace")
+                for w in re.findall(r'(?u)\b\w{2,25}\b', text)]
+        words = {}
+        for word in wordlist:
+            if self.is_stopword(word): continue
+            if len(word) > 25: continue
+            words[word] = 1
+        words = words.keys()
+
+        # for each word, add an entry in the db
+        for word in words:
+            # don't dupe
+            sql = 'select * from __words where _word=%s and _textid=%s'%(a, a)
+            self.db.cursor.execute(sql, (word, id))
+            if self.db.cursor.fetchall():
+                continue
+            sql = 'insert into __words (_word, _textid) values (%s, %s)'%(a, a)
+            self.db.cursor.execute(sql, (word, id))
+
+    def find(self, wordlist):
+        '''look up all the words in the wordlist.
+        If none are found return an empty dictionary
+        * more rules here
+        '''
+        if not wordlist:
+            return {}
+
+        l = [word.upper() for word in wordlist if 26 > len(word) > 2]
+
+        if not l:
+            return {}
+
+        if self.db.implements_intersect:
+            # simple AND search
+            sql = 'select distinct(_textid) from __words where _word=%s'%self.db.arg
+            sql = '\nINTERSECT\n'.join([sql]*len(l))
+            self.db.cursor.execute(sql, tuple(l))
+            r = self.db.cursor.fetchall()
+            if not r:
+                return {}
+            a = ','.join([self.db.arg] * len(r))
+            sql = 'select _class, _itemid, _prop from __textids '\
+                'where _textid in (%s)'%a
+            self.db.cursor.execute(sql, tuple([int(id) for (id,) in r]))
+
+        else:
+            # A more complex version for MySQL since it doesn't implement INTERSECT
+
+            # Construct SQL statement to join __words table to itself
+            # multiple times.
+            sql = """select distinct(__words1._textid)
+                        from __words as __words1 %s
+                        where __words1._word=%s %s"""
+
+            join_tmpl = ' left join __words as __words%d using (_textid) \n'
+            match_tmpl = ' and __words%d._word=%s \n'
+
+            join_list = []
+            match_list = []
+            for n in xrange(len(l) - 1):
+                join_list.append(join_tmpl % (n + 2))
+                match_list.append(match_tmpl % (n + 2, self.db.arg))
+
+            sql = sql%(' '.join(join_list), self.db.arg, ' '.join(match_list))
+            self.db.cursor.execute(sql, l)
+
+            r = map(lambda x: x[0], self.db.cursor.fetchall())
+            if not r:
+                return {}
+
+            a = ','.join([self.db.arg] * len(r))
+            sql = 'select _class, _itemid, _prop from __textids '\
+                'where _textid in (%s)'%a
+
+            self.db.cursor.execute(sql, tuple(map(int, r)))
+
+        return self.db.cursor.fetchall()
+

Added: tracker/vendor/roundup/current/roundup/backends/indexer_xapian.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/backends/indexer_xapian.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,124 @@
+#$Id: indexer_xapian.py,v 1.4 2006/02/10 00:16:13 richard Exp $
+''' This implements the full-text indexer using the Xapian indexer.
+'''
+import re, os
+
+import xapian
+
+from roundup.backends.indexer_common import Indexer as IndexerBase
+
+# TODO: we need to delete documents when a property is *reindexed*
+
+class Indexer(IndexerBase):
+    def __init__(self, db):
+        IndexerBase.__init__(self, db)
+        self.db_path = db.config.DATABASE
+        self.reindex = 0
+        self.transaction_active = False
+
+    def _get_database(self):
+        index = os.path.join(self.db_path, 'text-index')
+        return xapian.WritableDatabase(index, xapian.DB_CREATE_OR_OPEN)
+
+    def save_index(self):
+        '''Save the changes to the index.'''
+        if not self.transaction_active:
+            return
+        # XXX: Xapian databases don't actually implement transactions yet
+        database = self._get_database()
+        database.commit_transaction()
+        self.transaction_active = False
+
+    def close(self):
+        '''close the indexing database'''
+        pass
+  
+    def rollback(self):
+        if not self.transaction_active:
+            return
+        # XXX: Xapian databases don't actually implement transactions yet
+        database = self._get_database()
+        database.cancel_transaction()
+        self.transaction_active = False
+
+    def force_reindex(self):
+        '''Force a reindexing of the database.  This essentially
+        empties the tables ids and index and sets a flag so
+        that the databases are reindexed'''
+        self.reindex = 1
+
+    def should_reindex(self):
+        '''returns True if the indexes need to be rebuilt'''
+        return self.reindex
+
+    def add_text(self, identifier, text, mime_type='text/plain'):
+        ''' "identifier" is  (classname, itemid, property) '''
+        if mime_type != 'text/plain':
+            return
+        if not text: text = ''
+
+        # open the database and start a transaction if needed
+        database = self._get_database()
+        # XXX: Xapian databases don't actually implement transactions yet
+        #if not self.transaction_active:
+            #database.begin_transaction()
+            #self.transaction_active = True
+
+        # TODO: allow configuration of other languages
+        stemmer = xapian.Stem("english")
+
+        # We use the identifier twice: once in the actual "text" being
+        # indexed so we can search on it, and again as the "data" being
+        # indexed so we know what we're matching when we get results
+        identifier = '%s:%s:%s'%identifier
+
+        # see if the id is in the database
+        enquire = xapian.Enquire(database)
+        query = xapian.Query(xapian.Query.OP_AND, [identifier])
+        enquire.set_query(query)
+        matches = enquire.get_mset(0, 10)
+        if matches.size():      # would it killya to implement __len__()??
+            b = matches.begin()
+            docid = b.get_docid()
+        else:
+            docid = None
+
+        # create the new document
+        doc = xapian.Document()
+        doc.set_data(identifier)
+        doc.add_posting(identifier, 0)
+
+        for match in re.finditer(r'\b\w{2,25}\b', text.upper()):
+            word = match.group(0)
+            if self.is_stopword(word):
+                continue
+            term = stemmer.stem_word(word)
+            doc.add_posting(term, match.start(0))
+        if docid:
+            database.replace_document(docid, doc)
+        else:
+            database.add_document(doc)
+
+    def find(self, wordlist):
+        '''look up all the words in the wordlist.
+        If none are found return an empty dictionary
+        * more rules here
+        '''        
+        if not wordlist:
+            return {}
+
+        database = self._get_database()
+
+        enquire = xapian.Enquire(database)
+        stemmer = xapian.Stem("english")
+        terms = []
+        for term in [word.upper() for word in wordlist if 26 > len(word) > 2]:
+            terms.append(stemmer.stem_word(term.upper()))
+        query = xapian.Query(xapian.Query.OP_AND, terms)
+
+        enquire.set_query(query)
+        matches = enquire.get_mset(0, 10)
+
+        return [tuple(m[xapian.MSET_DOCUMENT].get_data().split(':'))
+            for m in matches]
+

Added: tracker/vendor/roundup/current/roundup/backends/locking.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/backends/locking.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,47 @@
+#! /usr/bin/env python
+# Copyright (c) 2002 ekit.com Inc (http://www.ekit-inc.com/)
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+#   The above copyright notice and this permission notice shall be included in
+#   all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+# $Id: locking.py,v 1.8 2004/02/11 23:55:09 richard Exp $
+
+'''This module provides a generic interface to acquire and release
+exclusive access to a file.
+
+It should work on Unix and Windows.
+'''
+__docformat__ = 'restructuredtext'
+
+import portalocker
+
+def acquire_lock(path, block=1):
+    '''Acquire a lock for the given path
+    '''
+    import portalocker
+    file = open(path, 'w')
+    if block:
+        portalocker.lock(file, portalocker.LOCK_EX)
+    else:
+        portalocker.lock(file, portalocker.LOCK_EX|portalocker.LOCK_NB)
+    return file
+
+def release_lock(file):
+    '''Release our lock on the given path
+    '''
+    portalocker.unlock(file)

Added: tracker/vendor/roundup/current/roundup/backends/portalocker.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/backends/portalocker.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,145 @@
+# portalocker.py - Cross-platform (posix/nt) API for flock-style file locking.
+#                  Requires python 1.5.2 or better.
+
+# ID line added by richard for Roundup file tracking
+# $Id: portalocker.py,v 1.8 2004/02/11 23:55:09 richard Exp $
+
+"""Cross-platform (posix/nt) API for flock-style file locking.
+
+Synopsis::
+
+   import portalocker
+   file = open("somefile", "r+")
+   portalocker.lock(file, portalocker.LOCK_EX)
+   file.seek(12)
+   file.write("foo")
+   file.close()
+
+If you know what you're doing, you may choose to::
+
+   portalocker.unlock(file)
+
+before closing the file, but why?
+
+Methods::
+
+   lock( file, flags )
+   unlock( file )
+
+Constants::
+
+   LOCK_EX
+   LOCK_SH
+   LOCK_NB
+
+I learned the win32 technique for locking files from sample code
+provided by John Nielsen <nielsenjf at my-deja.com> in the documentation
+that accompanies the win32 modules.
+
+:Author: Jonathan Feinberg <jdf at pobox.com>
+:Version: Id: portalocker.py,v 1.3 2001/05/29 18:47:55 Administrator Exp 
+          **un-cvsified by richard so the version doesn't change**
+"""
+__docformat__ = 'restructuredtext'
+
+import os
+
+if os.name == 'nt':
+    import win32con
+    import win32file
+    import pywintypes
+    LOCK_EX = win32con.LOCKFILE_EXCLUSIVE_LOCK
+    LOCK_SH = 0 # the default
+    LOCK_NB = win32con.LOCKFILE_FAIL_IMMEDIATELY
+    # is there any reason not to reuse the following structure?
+    __overlapped = pywintypes.OVERLAPPED()
+elif os.name == 'posix':
+    import fcntl
+    LOCK_EX = fcntl.LOCK_EX
+    LOCK_SH = fcntl.LOCK_SH
+    LOCK_NB = fcntl.LOCK_NB
+else:
+    raise RuntimeError("PortaLocker only defined for nt and posix platforms")
+
+if os.name == 'nt':
+    # eugh, we want 0xffff0000 here, but python 2.3 won't let us :(
+    FFFF0000 = -65536
+    def lock(file, flags):
+        hfile = win32file._get_osfhandle(file.fileno())
+        # LockFileEx is not supported on all Win32 platforms (Win95, Win98, WinME).
+        # If it's not supported, win32file will raise an exception.
+        # Try LockFileEx first, as it has more functionality and handles
+        # blocking locks more efficiently.
+        try:
+            win32file.LockFileEx(hfile, flags, 0, FFFF0000, __overlapped)
+        except win32file.error, e:
+            import winerror
+            # Propagate upwards all exceptions other than not-implemented.
+            if e[0] != winerror.ERROR_CALL_NOT_IMPLEMENTED:
+                raise e
+            
+            # LockFileEx is not supported. Use LockFile.
+            # LockFile does not support shared locking -- always exclusive.
+            # Care: the low/high length params are reversed compared to LockFileEx.
+            if not flags & LOCK_EX:
+                import warnings
+                warnings.warn("PortaLocker does not support shared locking on Win9x", RuntimeWarning)
+            # LockFile only supports immediate-fail locking.
+            if flags & LOCK_NB:
+                win32file.LockFile(hfile, 0, 0, FFFF0000, 0)
+            else:
+                # Emulate a blocking lock with a polling loop.
+                import time
+                while 1:
+                    # Attempt a lock.
+                    try:
+                        win32file.LockFile(hfile, 0, 0, FFFF0000, 0)
+                        break
+                    except win32file.error, e:
+                        # Propagate upwards all exceptions other than lock violation.
+                        if e[0] != winerror.ERROR_LOCK_VIOLATION:
+                            raise e
+                    # Sleep and poll again.
+                    time.sleep(0.1)
+        # TODO: should this return the result of the lock?
+                    
+    def unlock(file):
+        hfile = win32file._get_osfhandle(file.fileno())
+        # UnlockFileEx is not supported on all Win32 platforms (Win95, Win98, WinME).
+        # If it's not supported, win32file will raise an api_error exception.
+        try:
+            win32file.UnlockFileEx(hfile, 0, FFFF0000, __overlapped)
+        except win32file.error, e:
+            import winerror
+            # Propagate upwards all exceptions other than not-implemented.
+            if e[0] != winerror.ERROR_CALL_NOT_IMPLEMENTED:
+                raise e
+            
+            # UnlockFileEx is not supported. Use UnlockFile.
+            # Care: the low/high length params are reversed compared to UnLockFileEx.
+            win32file.UnlockFile(hfile, 0, 0, FFFF0000, 0)
+
+elif os.name =='posix':
+    def lock(file, flags):
+        fcntl.flock(file.fileno(), flags)
+        # TODO: should this return the result of the lock?
+
+    def unlock(file):
+        fcntl.flock(file.fileno(), fcntl.LOCK_UN)
+
+if __name__ == '__main__':
+    from time import time, strftime, localtime
+    import sys
+    import portalocker
+
+    log = open('log.txt', "a+")
+    portalocker.lock(log, portalocker.LOCK_EX)
+
+    timestamp = strftime("%m/%d/%Y %H:%M:%S\n", localtime(time()))
+    log.write( timestamp )
+
+    print "Wrote lines. Hit enter to release lock."
+    dummy = sys.stdin.readline()
+
+    log.close()
+

Added: tracker/vendor/roundup/current/roundup/backends/rdbms_common.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/backends/rdbms_common.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,2649 @@
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+#
+#$Id: rdbms_common.py,v 1.170 2006/04/27 05:15:17 richard Exp $
+''' Relational database (SQL) backend common code.
+
+Basics:
+
+- map roundup classes to relational tables
+- automatically detect schema changes and modify the table schemas
+  appropriately (we store the "database version" of the schema in the
+  database itself as the only row of the "schema" table)
+- multilinks (which represent a many-to-many relationship) are handled through
+  intermediate tables
+- journals are stored adjunct to the per-class tables
+- table names and columns have "_" prepended so the names can't clash with
+  restricted names (like "order")
+- retirement is determined by the __retired__ column being true
+
+Database-specific changes may generally be pushed out to the overridable
+sql_* methods, since everything else should be fairly generic. There's
+probably a bit of work to be done if a database is used that actually
+honors column typing, since the initial databases don't (sqlite stores
+everything as a string.)
+
+The schema of the hyperdb being mapped to the database is stored in the
+database itself as a repr()'ed dictionary of information about each Class
+that maps to a table. If that information differs from the hyperdb schema,
+then we update it. We also store in the schema dict a version which
+allows us to upgrade the database schema when necessary. See upgrade_db().
+'''
+__docformat__ = 'restructuredtext'
+
+# standard python modules
+import sys, os, time, re, errno, weakref, copy, logging
+
+# roundup modules
+from roundup import hyperdb, date, password, roundupdb, security, support
+from roundup.hyperdb import String, Password, Date, Interval, Link, \
+    Multilink, DatabaseError, Boolean, Number, Node
+from roundup.backends import locking
+
+# support
+from blobfiles import FileStorage
+try:
+    from indexer_xapian import Indexer
+except ImportError:
+    from indexer_rdbms import Indexer
+from sessions_rdbms import Sessions, OneTimeKeys
+from roundup.date import Range
+
+# number of rows to keep in memory
+ROW_CACHE_SIZE = 100
+
+# dummy value meaning "argument not passed"
+_marker = []
+
+def _num_cvt(num):
+    num = str(num)
+    try:
+        return int(num)
+    except:
+        return float(num)
+
+def _bool_cvt(value):
+    if value in ('TRUE', 'FALSE'):
+        return {'TRUE': 1, 'FALSE': 0}[value]
+    # assume it's a number returned from the db API
+    return int(value)
+
+def connection_dict(config, dbnamestr=None):
+    ''' Used by Postgresql and MySQL to detemine the keyword args for
+    opening the database connection.'''
+    d = { }
+    if dbnamestr:
+        d[dbnamestr] = config.RDBMS_NAME
+    for name in ('host', 'port', 'password', 'user', 'read_default_group',
+            'read_default_file'):
+        cvar = 'RDBMS_'+name.upper()
+        if config[cvar] is not None:
+            d[name] = config[cvar]
+    return d
+
+class Database(FileStorage, hyperdb.Database, roundupdb.Database):
+    ''' Wrapper around an SQL database that presents a hyperdb interface.
+
+        - some functionality is specific to the actual SQL database, hence
+          the sql_* methods that are NotImplemented
+        - we keep a cache of the latest ROW_CACHE_SIZE row fetches.
+    '''
+    def __init__(self, config, journaltag=None):
+        ''' Open the database and load the schema from it.
+        '''
+        self.config, self.journaltag = config, journaltag
+        self.dir = config.DATABASE
+        self.classes = {}
+        self.indexer = Indexer(self)
+        self.security = security.Security(self)
+
+        # additional transaction support for external files and the like
+        self.transactions = []
+
+        # keep a cache of the N most recently retrieved rows of any kind
+        # (classname, nodeid) = row
+        self.cache = {}
+        self.cache_lru = []
+        self.stats = {'cache_hits': 0, 'cache_misses': 0, 'get_items': 0,
+            'filtering': 0}
+
+        # database lock
+        self.lockfile = None
+
+        # open a connection to the database, creating the "conn" attribute
+        self.open_connection()
+
+    def clearCache(self):
+        self.cache = {}
+        self.cache_lru = []
+
+    def getSessionManager(self):
+        return Sessions(self)
+
+    def getOTKManager(self):
+        return OneTimeKeys(self)
+
+    def open_connection(self):
+        ''' Open a connection to the database, creating it if necessary.
+
+            Must call self.load_dbschema()
+        '''
+        raise NotImplemented
+
+    def sql(self, sql, args=None):
+        ''' Execute the sql with the optional args.
+        '''
+        if __debug__:
+            logging.getLogger('hyperdb').debug('SQL %r %r'%(sql, args))
+        if args:
+            self.cursor.execute(sql, args)
+        else:
+            self.cursor.execute(sql)
+
+    def sql_fetchone(self):
+        ''' Fetch a single row. If there's nothing to fetch, return None.
+        '''
+        return self.cursor.fetchone()
+
+    def sql_fetchall(self):
+        ''' Fetch all rows. If there's nothing to fetch, return [].
+        '''
+        return self.cursor.fetchall()
+
+    def sql_stringquote(self, value):
+        ''' Quote the string so it's safe to put in the 'sql quotes'
+        '''
+        return re.sub("'", "''", str(value))
+
+    def init_dbschema(self):
+        self.database_schema = {
+            'version': self.current_db_version,
+            'tables': {}
+        }
+
+    def load_dbschema(self):
+        ''' Load the schema definition that the database currently implements
+        '''
+        self.cursor.execute('select schema from schema')
+        schema = self.cursor.fetchone()
+        if schema:
+            self.database_schema = eval(schema[0])
+        else:
+            self.database_schema = {}
+
+    def save_dbschema(self):
+        ''' Save the schema definition that the database currently implements
+        '''
+        s = repr(self.database_schema)
+        self.sql('delete from schema')
+        self.sql('insert into schema values (%s)', (s,))
+
+    def post_init(self):
+        ''' Called once the schema initialisation has finished.
+
+            We should now confirm that the schema defined by our "classes"
+            attribute actually matches the schema in the database.
+        '''
+        save = 0
+
+        # handle changes in the schema
+        tables = self.database_schema['tables']
+        for classname, spec in self.classes.items():
+            if tables.has_key(classname):
+                dbspec = tables[classname]
+                if self.update_class(spec, dbspec):
+                    tables[classname] = spec.schema()
+                    save = 1
+            else:
+                self.create_class(spec)
+                tables[classname] = spec.schema()
+                save = 1
+
+        for classname, spec in tables.items():
+            if not self.classes.has_key(classname):
+                self.drop_class(classname, tables[classname])
+                del tables[classname]
+                save = 1
+
+        # now upgrade the database for column type changes, new internal
+        # tables, etc.
+        save = save | self.upgrade_db()
+
+        # update the database version of the schema
+        if save:
+            self.save_dbschema()
+
+        # reindex the db if necessary
+        if self.indexer.should_reindex():
+            self.reindex()
+
+        # commit
+        self.sql_commit()
+
+    # update this number when we need to make changes to the SQL structure
+    # of the backen database
+    current_db_version = 4
+    def upgrade_db(self):
+        ''' Update the SQL database to reflect changes in the backend code.
+
+            Return boolean whether we need to save the schema.
+        '''
+        version = self.database_schema.get('version', 1)
+        if version == self.current_db_version:
+            # nothing to do
+            return 0
+
+        if version < 2:
+            if __debug__:
+                logging.getLogger('hyperdb').info('upgrade to version 2')
+            # change the schema structure
+            self.database_schema = {'tables': self.database_schema}
+
+            # version 1 didn't have the actor column (note that in
+            # MySQL this will also transition the tables to typed columns)
+            self.add_new_columns_v2()
+
+            # version 1 doesn't have the OTK, session and indexing in the
+            # database
+            self.create_version_2_tables()
+
+        if version < 3:
+            if __debug__:
+                logging.getLogger('hyperdb').info('upgrade to version 3')
+            self.fix_version_2_tables()
+
+        if version < 4:
+            self.fix_version_3_tables()
+
+        self.database_schema['version'] = self.current_db_version
+        return 1
+
+    def fix_version_3_tables(self):
+        # drop the shorter VARCHAR OTK column and add a new TEXT one
+        for name in ('otk', 'session'):
+            self.sql('DELETE FROM %ss'%name)
+            self.sql('ALTER TABLE %ss DROP %s_value'%(name, name))
+            self.sql('ALTER TABLE %ss ADD %s_value TEXT'%(name, name))
+
+    def fix_version_2_tables(self):
+        '''Default (used by sqlite): NOOP'''
+        pass
+
+    def _convert_journal_tables(self):
+        '''Get current journal table contents, drop the table and re-create'''
+        c = self.cursor
+        cols = ','.join('nodeid date tag action params'.split())
+        for klass in self.classes.values():
+            # slurp and drop
+            sql = 'select %s from %s__journal order by date'%(cols,
+                klass.classname)
+            c.execute(sql)
+            contents = c.fetchall()
+            self.drop_journal_table_indexes(klass.classname)
+            c.execute('drop table %s__journal'%klass.classname)
+
+            # re-create and re-populate
+            self.create_journal_table(klass)
+            a = self.arg
+            sql = 'insert into %s__journal (%s) values (%s,%s,%s,%s,%s)'%(
+                klass.classname, cols, a, a, a, a, a)
+            for row in contents:
+                # no data conversion needed
+                self.cursor.execute(sql, row)
+
+    def _convert_string_properties(self):
+        '''Get current Class tables that contain String properties, and
+        convert the VARCHAR columns to TEXT'''
+        c = self.cursor
+        for klass in self.classes.values():
+            # slurp and drop
+            cols, mls = self.determine_columns(klass.properties.items())
+            scols = ','.join([i[0] for i in cols])
+            sql = 'select id,%s from _%s'%(scols, klass.classname)
+            c.execute(sql)
+            contents = c.fetchall()
+            self.drop_class_table_indexes(klass.classname, klass.getkey())
+            c.execute('drop table _%s'%klass.classname)
+
+            # re-create and re-populate
+            self.create_class_table(klass, create_sequence=0)
+            a = ','.join([self.arg for i in range(len(cols)+1)])
+            sql = 'insert into _%s (id,%s) values (%s)'%(klass.classname,
+                scols, a)
+            for row in contents:
+                l = []
+                for entry in row:
+                    # mysql will already be a string - psql needs "help"
+                    if entry is not None and not isinstance(entry, type('')):
+                        entry = str(entry)
+                    l.append(entry)
+                self.cursor.execute(sql, l)
+
+    def refresh_database(self):
+        self.post_init()
+
+
+    def reindex(self, classname=None, show_progress=False):
+        if classname:
+            classes = [self.getclass(classname)]
+        else:
+            classes = self.classes.values()
+        for klass in classes:
+            if show_progress:
+                for nodeid in support.Progress('Reindex %s'%klass.classname,
+                        klass.list()):
+                    klass.index(nodeid)
+            else:
+                for nodeid in klass.list():
+                    klass.index(nodeid)
+        self.indexer.save_index()
+
+    hyperdb_to_sql_datatypes = {
+        hyperdb.String : 'TEXT',
+        hyperdb.Date   : 'TIMESTAMP',
+        hyperdb.Link   : 'INTEGER',
+        hyperdb.Interval  : 'VARCHAR(255)',
+        hyperdb.Password  : 'VARCHAR(255)',
+        hyperdb.Boolean   : 'BOOLEAN',
+        hyperdb.Number    : 'REAL',
+    }
+    def determine_columns(self, properties):
+        ''' Figure the column names and multilink properties from the spec
+
+            "properties" is a list of (name, prop) where prop may be an
+            instance of a hyperdb "type" _or_ a string repr of that type.
+        '''
+        cols = [
+            ('_actor', self.hyperdb_to_sql_datatypes[hyperdb.Link]),
+            ('_activity', self.hyperdb_to_sql_datatypes[hyperdb.Date]),
+            ('_creator', self.hyperdb_to_sql_datatypes[hyperdb.Link]),
+            ('_creation', self.hyperdb_to_sql_datatypes[hyperdb.Date]),
+        ]
+        mls = []
+        # add the multilinks separately
+        for col, prop in properties:
+            if isinstance(prop, Multilink):
+                mls.append(col)
+                continue
+
+            if isinstance(prop, type('')):
+                raise ValueError, "string property spec!"
+                #and prop.find('Multilink') != -1:
+                #mls.append(col)
+
+            datatype = self.hyperdb_to_sql_datatypes[prop.__class__]
+            cols.append(('_'+col, datatype))
+
+            # Intervals stored as two columns
+            if isinstance(prop, Interval):
+                cols.append(('__'+col+'_int__', 'BIGINT'))
+
+        cols.sort()
+        return cols, mls
+
+    def update_class(self, spec, old_spec, force=0):
+        ''' Determine the differences between the current spec and the
+            database version of the spec, and update where necessary.
+
+            If 'force' is true, update the database anyway.
+        '''
+        new_has = spec.properties.has_key
+        new_spec = spec.schema()
+        new_spec[1].sort()
+        old_spec[1].sort()
+        if not force and new_spec == old_spec:
+            # no changes
+            return 0
+
+        logger = logging.getLogger('hyperdb')
+        logger.info('update_class %s'%spec.classname)
+
+        logger.debug('old_spec %r'%(old_spec,))
+        logger.debug('new_spec %r'%(new_spec,))
+
+        # detect key prop change for potential index change
+        keyprop_changes = {}
+        if new_spec[0] != old_spec[0]:
+            if old_spec[0]:
+                keyprop_changes['remove'] = old_spec[0]
+            if new_spec[0]:
+                keyprop_changes['add'] = new_spec[0]
+
+        # detect multilinks that have been removed, and drop their table
+        old_has = {}
+        for name, prop in old_spec[1]:
+            old_has[name] = 1
+            if new_has(name):
+                continue
+
+            if prop.find('Multilink to') != -1:
+                # first drop indexes.
+                self.drop_multilink_table_indexes(spec.classname, name)
+
+                # now the multilink table itself
+                sql = 'drop table %s_%s'%(spec.classname, name)
+            else:
+                # if this is the key prop, drop the index first
+                if old_spec[0] == prop:
+                    self.drop_class_table_key_index(spec.classname, name)
+                    del keyprop_changes['remove']
+
+                # drop the column
+                sql = 'alter table _%s drop column _%s'%(spec.classname, name)
+
+            self.sql(sql)
+        old_has = old_has.has_key
+
+        # if we didn't remove the key prop just then, but the key prop has
+        # changed, we still need to remove the old index
+        if keyprop_changes.has_key('remove'):
+            self.drop_class_table_key_index(spec.classname,
+                keyprop_changes['remove'])
+
+        # add new columns
+        for propname, prop in new_spec[1]:
+            if old_has(propname):
+                continue
+            prop = spec.properties[propname]
+            if isinstance(prop, Multilink):
+                self.create_multilink_table(spec, propname)
+            else:
+                # add the column
+                coltype = self.hyperdb_to_sql_datatypes[prop.__class__]
+                sql = 'alter table _%s add column _%s %s'%(
+                    spec.classname, propname, coltype)
+                self.sql(sql)
+
+                # extra Interval column
+                if isinstance(prop, Interval):
+                    sql = 'alter table _%s add column __%s_int__ BIGINT'%(
+                        spec.classname, propname)
+                    self.sql(sql)
+
+                # if the new column is a key prop, we need an index!
+                if new_spec[0] == propname:
+                    self.create_class_table_key_index(spec.classname, propname)
+                    del keyprop_changes['add']
+
+        # if we didn't add the key prop just then, but the key prop has
+        # changed, we still need to add the new index
+        if keyprop_changes.has_key('add'):
+            self.create_class_table_key_index(spec.classname,
+                keyprop_changes['add'])
+
+        return 1
+
+    def determine_all_columns(self, spec):
+        """Figure out the columns from the spec and also add internal columns
+
+        """
+        cols, mls = self.determine_columns(spec.properties.items())
+
+        # add on our special columns
+        cols.append(('id', 'INTEGER PRIMARY KEY'))
+        cols.append(('__retired__', 'INTEGER DEFAULT 0'))
+        return cols, mls
+
+    def create_class_table(self, spec):
+        '''Create the class table for the given Class "spec". Creates the
+        indexes too.'''
+        cols, mls = self.determine_all_columns(spec)
+
+        # create the base table
+        scols = ','.join(['%s %s'%x for x in cols])
+        sql = 'create table _%s (%s)'%(spec.classname, scols)
+        self.sql(sql)
+
+        self.create_class_table_indexes(spec)
+
+        return cols, mls
+
+    def create_class_table_indexes(self, spec):
+        ''' create the class table for the given spec
+        '''
+        # create __retired__ index
+        index_sql2 = 'create index _%s_retired_idx on _%s(__retired__)'%(
+                        spec.classname, spec.classname)
+        self.sql(index_sql2)
+
+        # create index for key property
+        if spec.key:
+            index_sql3 = 'create index _%s_%s_idx on _%s(_%s)'%(
+                        spec.classname, spec.key,
+                        spec.classname, spec.key)
+            self.sql(index_sql3)
+
+        # TODO: create indexes on (selected?) Link property columns, as
+        # they're more likely to be used for lookup
+
+    def drop_class_table_indexes(self, cn, key):
+        # drop the old table indexes first
+        l = ['_%s_id_idx'%cn, '_%s_retired_idx'%cn]
+        if key:
+            l.append('_%s_%s_idx'%(cn, key))
+
+        table_name = '_%s'%cn
+        for index_name in l:
+            if not self.sql_index_exists(table_name, index_name):
+                continue
+            index_sql = 'drop index '+index_name
+            self.sql(index_sql)
+
+    def create_class_table_key_index(self, cn, key):
+        ''' create the class table for the given spec
+        '''
+        sql = 'create index _%s_%s_idx on _%s(_%s)'%(cn, key, cn, key)
+        self.sql(sql)
+
+    def drop_class_table_key_index(self, cn, key):
+        table_name = '_%s'%cn
+        index_name = '_%s_%s_idx'%(cn, key)
+        if not self.sql_index_exists(table_name, index_name):
+            return
+        sql = 'drop index '+index_name
+        self.sql(sql)
+
+    def create_journal_table(self, spec):
+        ''' create the journal table for a class given the spec and
+            already-determined cols
+        '''
+        # journal table
+        cols = ','.join(['%s varchar'%x
+            for x in 'nodeid date tag action params'.split()])
+        sql = '''create table %s__journal (
+            nodeid integer, date %s, tag varchar(255),
+            action varchar(255), params text)''' % (spec.classname,
+            self.hyperdb_to_sql_datatypes[hyperdb.Date])
+        self.sql(sql)
+        self.create_journal_table_indexes(spec)
+
+    def create_journal_table_indexes(self, spec):
+        # index on nodeid
+        sql = 'create index %s_journ_idx on %s__journal(nodeid)'%(
+                        spec.classname, spec.classname)
+        self.sql(sql)
+
+    def drop_journal_table_indexes(self, classname):
+        index_name = '%s_journ_idx'%classname
+        if not self.sql_index_exists('%s__journal'%classname, index_name):
+            return
+        index_sql = 'drop index '+index_name
+        self.sql(index_sql)
+
+    def create_multilink_table(self, spec, ml):
+        ''' Create a multilink table for the "ml" property of the class
+            given by the spec
+        '''
+        # create the table
+        sql = 'create table %s_%s (linkid INTEGER, nodeid INTEGER)'%(
+            spec.classname, ml)
+        self.sql(sql)
+        self.create_multilink_table_indexes(spec, ml)
+
+    def create_multilink_table_indexes(self, spec, ml):
+        # create index on linkid
+        index_sql = 'create index %s_%s_l_idx on %s_%s(linkid)'%(
+            spec.classname, ml, spec.classname, ml)
+        self.sql(index_sql)
+
+        # create index on nodeid
+        index_sql = 'create index %s_%s_n_idx on %s_%s(nodeid)'%(
+            spec.classname, ml, spec.classname, ml)
+        self.sql(index_sql)
+
+    def drop_multilink_table_indexes(self, classname, ml):
+        l = [
+            '%s_%s_l_idx'%(classname, ml),
+            '%s_%s_n_idx'%(classname, ml)
+        ]
+        table_name = '%s_%s'%(classname, ml)
+        for index_name in l:
+            if not self.sql_index_exists(table_name, index_name):
+                continue
+            index_sql = 'drop index %s'%index_name
+            self.sql(index_sql)
+
+    def create_class(self, spec):
+        ''' Create a database table according to the given spec.
+        '''
+        cols, mls = self.create_class_table(spec)
+        self.create_journal_table(spec)
+
+        # now create the multilink tables
+        for ml in mls:
+            self.create_multilink_table(spec, ml)
+
+    def drop_class(self, cn, spec):
+        ''' Drop the given table from the database.
+
+            Drop the journal and multilink tables too.
+        '''
+        properties = spec[1]
+        # figure the multilinks
+        mls = []
+        for propanme, prop in properties:
+            if isinstance(prop, Multilink):
+                mls.append(propname)
+
+        # drop class table and indexes
+        self.drop_class_table_indexes(cn, spec[0])
+
+        self.drop_class_table(cn)
+
+        # drop journal table and indexes
+        self.drop_journal_table_indexes(cn)
+        sql = 'drop table %s__journal'%cn
+        self.sql(sql)
+
+        for ml in mls:
+            # drop multilink table and indexes
+            self.drop_multilink_table_indexes(cn, ml)
+            sql = 'drop table %s_%s'%(spec.classname, ml)
+            self.sql(sql)
+
+    def drop_class_table(self, cn):
+        sql = 'drop table _%s'%cn
+        self.sql(sql)
+
+    #
+    # Classes
+    #
+    def __getattr__(self, classname):
+        ''' A convenient way of calling self.getclass(classname).
+        '''
+        if self.classes.has_key(classname):
+            return self.classes[classname]
+        raise AttributeError, classname
+
+    def addclass(self, cl):
+        ''' Add a Class to the hyperdatabase.
+        '''
+        cn = cl.classname
+        if self.classes.has_key(cn):
+            raise ValueError, cn
+        self.classes[cn] = cl
+
+        # add default Edit and View permissions
+        self.security.addPermission(name="Create", klass=cn,
+            description="User is allowed to create "+cn)
+        self.security.addPermission(name="Edit", klass=cn,
+            description="User is allowed to edit "+cn)
+        self.security.addPermission(name="View", klass=cn,
+            description="User is allowed to access "+cn)
+
+    def getclasses(self):
+        ''' Return a list of the names of all existing classes.
+        '''
+        l = self.classes.keys()
+        l.sort()
+        return l
+
+    def getclass(self, classname):
+        '''Get the Class object representing a particular class.
+
+        If 'classname' is not a valid class name, a KeyError is raised.
+        '''
+        try:
+            return self.classes[classname]
+        except KeyError:
+            raise KeyError, 'There is no class called "%s"'%classname
+
+    def clear(self):
+        '''Delete all database contents.
+
+        Note: I don't commit here, which is different behaviour to the
+              "nuke from orbit" behaviour in the dbs.
+        '''
+        logging.getLogger('hyperdb').info('clear')
+        for cn in self.classes.keys():
+            sql = 'delete from _%s'%cn
+            self.sql(sql)
+
+    #
+    # Nodes
+    #
+
+    hyperdb_to_sql_value = {
+        hyperdb.String : str,
+        # fractional seconds by default
+        hyperdb.Date   : lambda x: x.formal(sep=' ', sec='%06.3f'),
+        hyperdb.Link   : int,
+        hyperdb.Interval  : str,
+        hyperdb.Password  : str,
+        hyperdb.Boolean   : lambda x: x and 'TRUE' or 'FALSE',
+        hyperdb.Number    : lambda x: x,
+        hyperdb.Multilink : lambda x: x,    # used in journal marshalling
+    }
+    def addnode(self, classname, nodeid, node):
+        ''' Add the specified node to its class's db.
+        '''
+        if __debug__:
+            logging.getLogger('hyperdb').debug('addnode %s%s %r'%(classname,
+                nodeid, node))
+
+        # determine the column definitions and multilink tables
+        cl = self.classes[classname]
+        cols, mls = self.determine_columns(cl.properties.items())
+
+        # we'll be supplied these props if we're doing an import
+        values = node.copy()
+        if not values.has_key('creator'):
+            # add in the "calculated" properties (dupe so we don't affect
+            # calling code's node assumptions)
+            values['creation'] = values['activity'] = date.Date()
+            values['actor'] = values['creator'] = self.getuid()
+
+        cl = self.classes[classname]
+        props = cl.getprops(protected=1)
+        del props['id']
+
+        # default the non-multilink columns
+        for col, prop in props.items():
+            if not values.has_key(col):
+                if isinstance(prop, Multilink):
+                    values[col] = []
+                else:
+                    values[col] = None
+
+        # clear this node out of the cache if it's in there
+        key = (classname, nodeid)
+        if self.cache.has_key(key):
+            del self.cache[key]
+            self.cache_lru.remove(key)
+
+        # figure the values to insert
+        vals = []
+        for col,dt in cols:
+            # this is somewhat dodgy....
+            if col.endswith('_int__'):
+                # XXX eugh, this test suxxors
+                value = values[col[2:-6]]
+                # this is an Interval special "int" column
+                if value is not None:
+                    vals.append(value.as_seconds())
+                else:
+                    vals.append(value)
+                continue
+
+            prop = props[col[1:]]
+            value = values[col[1:]]
+            if value is not None:
+                value = self.hyperdb_to_sql_value[prop.__class__](value)
+            vals.append(value)
+        vals.append(nodeid)
+        vals = tuple(vals)
+
+        # make sure the ordering is correct for column name -> column value
+        s = ','.join([self.arg for x in cols]) + ',%s'%self.arg
+        cols = ','.join([col for col,dt in cols]) + ',id'
+
+        # perform the inserts
+        sql = 'insert into _%s (%s) values (%s)'%(classname, cols, s)
+        self.sql(sql, vals)
+
+        # insert the multilink rows
+        for col in mls:
+            t = '%s_%s'%(classname, col)
+            for entry in node[col]:
+                sql = 'insert into %s (linkid, nodeid) values (%s,%s)'%(t,
+                    self.arg, self.arg)
+                self.sql(sql, (entry, nodeid))
+
+    def setnode(self, classname, nodeid, values, multilink_changes={}):
+        ''' Change the specified node.
+        '''
+        if __debug__:
+            logging.getLogger('hyperdb').debug('setnode %s%s %r'
+                % (classname, nodeid, values))
+
+        # clear this node out of the cache if it's in there
+        key = (classname, nodeid)
+        if self.cache.has_key(key):
+            del self.cache[key]
+            self.cache_lru.remove(key)
+
+        cl = self.classes[classname]
+        props = cl.getprops()
+
+        cols = []
+        mls = []
+        # add the multilinks separately
+        for col in values.keys():
+            prop = props[col]
+            if isinstance(prop, Multilink):
+                mls.append(col)
+            elif isinstance(prop, Interval):
+                # Intervals store the seconds value too
+                cols.append(col)
+                # extra leading '_' added by code below
+                cols.append('_' +col + '_int__')
+            else:
+                cols.append(col)
+        cols.sort()
+
+        # figure the values to insert
+        vals = []
+        for col in cols:
+            if col.endswith('_int__'):
+                # XXX eugh, this test suxxors
+                # Intervals store the seconds value too
+                col = col[1:-6]
+                prop = props[col]
+                value = values[col]
+                if value is None:
+                    vals.append(None)
+                else:
+                    vals.append(value.as_seconds())
+            else:
+                prop = props[col]
+                value = values[col]
+                if value is None:
+                    e = None
+                else:
+                    e = self.hyperdb_to_sql_value[prop.__class__](value)
+                vals.append(e)
+
+        vals.append(int(nodeid))
+        vals = tuple(vals)
+
+        # if there's any updates to regular columns, do them
+        if cols:
+            # make sure the ordering is correct for column name -> column value
+            s = ','.join(['_%s=%s'%(x, self.arg) for x in cols])
+            cols = ','.join(cols)
+
+            # perform the update
+            sql = 'update _%s set %s where id=%s'%(classname, s, self.arg)
+            self.sql(sql, vals)
+
+        # we're probably coming from an import, not a change
+        if not multilink_changes:
+            for name in mls:
+                prop = props[name]
+                value = values[name]
+
+                t = '%s_%s'%(classname, name)
+
+                # clear out previous values for this node
+                # XXX numeric ids
+                self.sql('delete from %s where nodeid=%s'%(t, self.arg),
+                        (nodeid,))
+
+                # insert the values for this node
+                for entry in values[name]:
+                    sql = 'insert into %s (linkid, nodeid) values (%s,%s)'%(t,
+                        self.arg, self.arg)
+                    # XXX numeric ids
+                    self.sql(sql, (entry, nodeid))
+
+        # we have multilink changes to apply
+        for col, (add, remove) in multilink_changes.items():
+            tn = '%s_%s'%(classname, col)
+            if add:
+                sql = 'insert into %s (nodeid, linkid) values (%s,%s)'%(tn,
+                    self.arg, self.arg)
+                for addid in add:
+                    # XXX numeric ids
+                    self.sql(sql, (int(nodeid), int(addid)))
+            if remove:
+                sql = 'delete from %s where nodeid=%s and linkid=%s'%(tn,
+                    self.arg, self.arg)
+                for removeid in remove:
+                    # XXX numeric ids
+                    self.sql(sql, (int(nodeid), int(removeid)))
+
+    sql_to_hyperdb_value = {
+        hyperdb.String : str,
+        hyperdb.Date   : lambda x:date.Date(str(x).replace(' ', '.')),
+#        hyperdb.Link   : int,      # XXX numeric ids
+        hyperdb.Link   : str,
+        hyperdb.Interval  : date.Interval,
+        hyperdb.Password  : lambda x: password.Password(encrypted=x),
+        hyperdb.Boolean   : _bool_cvt,
+        hyperdb.Number    : _num_cvt,
+        hyperdb.Multilink : lambda x: x,    # used in journal marshalling
+    }
+    def getnode(self, classname, nodeid):
+        ''' Get a node from the database.
+        '''
+        # see if we have this node cached
+        key = (classname, nodeid)
+        if self.cache.has_key(key):
+            # push us back to the top of the LRU
+            self.cache_lru.remove(key)
+            self.cache_lru.insert(0, key)
+            if __debug__:
+                self.stats['cache_hits'] += 1
+            # return the cached information
+            return self.cache[key]
+
+        if __debug__:
+            self.stats['cache_misses'] += 1
+            start_t = time.time()
+
+        # figure the columns we're fetching
+        cl = self.classes[classname]
+        cols, mls = self.determine_columns(cl.properties.items())
+        scols = ','.join([col for col,dt in cols])
+
+        # perform the basic property fetch
+        sql = 'select %s from _%s where id=%s'%(scols, classname, self.arg)
+        self.sql(sql, (nodeid,))
+
+        values = self.sql_fetchone()
+        if values is None:
+            raise IndexError, 'no such %s node %s'%(classname, nodeid)
+
+        # make up the node
+        node = {}
+        props = cl.getprops(protected=1)
+        for col in range(len(cols)):
+            name = cols[col][0][1:]
+            if name.endswith('_int__'):
+                # XXX eugh, this test suxxors
+                # ignore the special Interval-as-seconds column
+                continue
+            value = values[col]
+            if value is not None:
+                value = self.sql_to_hyperdb_value[props[name].__class__](value)
+            node[name] = value
+
+
+        # now the multilinks
+        for col in mls:
+            # get the link ids
+            sql = 'select linkid from %s_%s where nodeid=%s'%(classname, col,
+                self.arg)
+            self.cursor.execute(sql, (nodeid,))
+            # extract the first column from the result
+            # XXX numeric ids
+            items = [int(x[0]) for x in self.cursor.fetchall()]
+            items.sort ()
+            node[col] = [str(x) for x in items]
+
+        # save off in the cache
+        key = (classname, nodeid)
+        self.cache[key] = node
+        # update the LRU
+        self.cache_lru.insert(0, key)
+        if len(self.cache_lru) > ROW_CACHE_SIZE:
+            del self.cache[self.cache_lru.pop()]
+
+        if __debug__:
+            self.stats['get_items'] += (time.time() - start_t)
+
+        return node
+
+    def destroynode(self, classname, nodeid):
+        '''Remove a node from the database. Called exclusively by the
+           destroy() method on Class.
+        '''
+        logging.getLogger('hyperdb').info('destroynode %s%s'%(classname, nodeid))
+
+        # make sure the node exists
+        if not self.hasnode(classname, nodeid):
+            raise IndexError, '%s has no node %s'%(classname, nodeid)
+
+        # see if we have this node cached
+        if self.cache.has_key((classname, nodeid)):
+            del self.cache[(classname, nodeid)]
+
+        # see if there's any obvious commit actions that we should get rid of
+        for entry in self.transactions[:]:
+            if entry[1][:2] == (classname, nodeid):
+                self.transactions.remove(entry)
+
+        # now do the SQL
+        sql = 'delete from _%s where id=%s'%(classname, self.arg)
+        self.sql(sql, (nodeid,))
+
+        # remove from multilnks
+        cl = self.getclass(classname)
+        x, mls = self.determine_columns(cl.properties.items())
+        for col in mls:
+            # get the link ids
+            sql = 'delete from %s_%s where nodeid=%s'%(classname, col, self.arg)
+            self.sql(sql, (nodeid,))
+
+        # remove journal entries
+        sql = 'delete from %s__journal where nodeid=%s'%(classname, self.arg)
+        self.sql(sql, (nodeid,))
+
+    def hasnode(self, classname, nodeid):
+        ''' Determine if the database has a given node.
+        '''
+        sql = 'select count(*) from _%s where id=%s'%(classname, self.arg)
+        self.sql(sql, (nodeid,))
+        return int(self.cursor.fetchone()[0])
+
+    def countnodes(self, classname):
+        ''' Count the number of nodes that exist for a particular Class.
+        '''
+        sql = 'select count(*) from _%s'%classname
+        self.sql(sql)
+        return self.cursor.fetchone()[0]
+
+    def addjournal(self, classname, nodeid, action, params, creator=None,
+            creation=None):
+        ''' Journal the Action
+        'action' may be:
+
+            'create' or 'set' -- 'params' is a dictionary of property values
+            'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
+            'retire' -- 'params' is None
+        '''
+        # handle supply of the special journalling parameters (usually
+        # supplied on importing an existing database)
+        if creator:
+            journaltag = creator
+        else:
+            journaltag = self.getuid()
+        if creation:
+            journaldate = creation
+        else:
+            journaldate = date.Date()
+
+        # create the journal entry
+        cols = 'nodeid,date,tag,action,params'
+
+        if __debug__:
+            logging.getLogger('hyperdb').debug('addjournal %s%s %r %s %s %r'%(classname,
+                nodeid, journaldate, journaltag, action, params))
+
+        # make the journalled data marshallable
+        if isinstance(params, type({})):
+            self._journal_marshal(params, classname)
+
+        params = repr(params)
+
+        dc = self.hyperdb_to_sql_value[hyperdb.Date]
+        journaldate = dc(journaldate)
+
+        self.save_journal(classname, cols, nodeid, journaldate,
+            journaltag, action, params)
+
+    def setjournal(self, classname, nodeid, journal):
+        '''Set the journal to the "journal" list.'''
+        # clear out any existing entries
+        self.sql('delete from %s__journal where nodeid=%s'%(classname,
+            self.arg), (nodeid,))
+
+        # create the journal entry
+        cols = 'nodeid,date,tag,action,params'
+
+        dc = self.hyperdb_to_sql_value[hyperdb.Date]
+        for nodeid, journaldate, journaltag, action, params in journal:
+            if __debug__:
+                logging.getLogger('hyperdb').debug('addjournal %s%s %r %s %s %r'%(
+                    classname, nodeid, journaldate, journaltag, action,
+                    params))
+
+            # make the journalled data marshallable
+            if isinstance(params, type({})):
+                self._journal_marshal(params, classname)
+            params = repr(params)
+
+            self.save_journal(classname, cols, nodeid, dc(journaldate),
+                journaltag, action, params)
+
+    def _journal_marshal(self, params, classname):
+        '''Convert the journal params values into safely repr'able and
+        eval'able values.'''
+        properties = self.getclass(classname).getprops()
+        for param, value in params.items():
+            if not value:
+                continue
+            property = properties[param]
+            cvt = self.hyperdb_to_sql_value[property.__class__]
+            if isinstance(property, Password):
+                params[param] = cvt(value)
+            elif isinstance(property, Date):
+                params[param] = cvt(value)
+            elif isinstance(property, Interval):
+                params[param] = cvt(value)
+            elif isinstance(property, Boolean):
+                params[param] = cvt(value)
+
+    def getjournal(self, classname, nodeid):
+        ''' get the journal for id
+        '''
+        # make sure the node exists
+        if not self.hasnode(classname, nodeid):
+            raise IndexError, '%s has no node %s'%(classname, nodeid)
+
+        cols = ','.join('nodeid date tag action params'.split())
+        journal = self.load_journal(classname, cols, nodeid)
+
+        # now unmarshal the data
+        dc = self.sql_to_hyperdb_value[hyperdb.Date]
+        res = []
+        properties = self.getclass(classname).getprops()
+        for nodeid, date_stamp, user, action, params in journal:
+            params = eval(params)
+            if isinstance(params, type({})):
+                for param, value in params.items():
+                    if not value:
+                        continue
+                    property = properties.get(param, None)
+                    if property is None:
+                        # deleted property
+                        continue
+                    cvt = self.sql_to_hyperdb_value[property.__class__]
+                    if isinstance(property, Password):
+                        params[param] = cvt(value)
+                    elif isinstance(property, Date):
+                        params[param] = cvt(value)
+                    elif isinstance(property, Interval):
+                        params[param] = cvt(value)
+                    elif isinstance(property, Boolean):
+                        params[param] = cvt(value)
+            # XXX numeric ids
+            res.append((str(nodeid), dc(date_stamp), user, action, params))
+        return res
+
+    def save_journal(self, classname, cols, nodeid, journaldate,
+            journaltag, action, params):
+        ''' Save the journal entry to the database
+        '''
+        entry = (nodeid, journaldate, journaltag, action, params)
+
+        # do the insert
+        a = self.arg
+        sql = 'insert into %s__journal (%s) values (%s,%s,%s,%s,%s)'%(
+            classname, cols, a, a, a, a, a)
+        self.sql(sql, entry)
+
+    def load_journal(self, classname, cols, nodeid):
+        ''' Load the journal from the database
+        '''
+        # now get the journal entries
+        sql = 'select %s from %s__journal where nodeid=%s order by date'%(
+            cols, classname, self.arg)
+        self.sql(sql, (nodeid,))
+        return self.cursor.fetchall()
+
+    def pack(self, pack_before):
+        ''' Delete all journal entries except "create" before 'pack_before'.
+        '''
+        date_stamp = self.hyperdb_to_sql_value[Date](pack_before)
+
+        # do the delete
+        for classname in self.classes.keys():
+            sql = "delete from %s__journal where date<%s and "\
+                "action<>'create'"%(classname, self.arg)
+            self.sql(sql, (date_stamp,))
+
+    def sql_commit(self):
+        ''' Actually commit to the database.
+        '''
+        logging.getLogger('hyperdb').info('commit')
+        self.conn.commit()
+
+        # open a new cursor for subsequent work
+        self.cursor = self.conn.cursor()
+
+    def commit(self):
+        ''' Commit the current transactions.
+
+        Save all data changed since the database was opened or since the
+        last commit() or rollback().
+        '''
+        # commit the database
+        self.sql_commit()
+
+        # now, do all the other transaction stuff
+        for method, args in self.transactions:
+            method(*args)
+
+        # save the indexer
+        self.indexer.save_index()
+
+        # clear out the transactions
+        self.transactions = []
+
+    def sql_rollback(self):
+        self.conn.rollback()
+
+    def rollback(self):
+        ''' Reverse all actions from the current transaction.
+
+        Undo all the changes made since the database was opened or the last
+        commit() or rollback() was performed.
+        '''
+        logging.getLogger('hyperdb').info('rollback')
+
+        self.sql_rollback()
+
+        # roll back "other" transaction stuff
+        for method, args in self.transactions:
+            # delete temporary files
+            if method == self.doStoreFile:
+                self.rollbackStoreFile(*args)
+        self.transactions = []
+
+        # clear the cache
+        self.clearCache()
+
+    def sql_close(self):
+        logging.getLogger('hyperdb').info('close')
+        self.conn.close()
+
+    def close(self):
+        ''' Close off the connection.
+        '''
+        self.indexer.close()
+        self.sql_close()
+
+#
+# The base Class class
+#
+class Class(hyperdb.Class):
+    ''' The handle to a particular class of nodes in a hyperdatabase.
+
+        All methods except __repr__ and getnode must be implemented by a
+        concrete backend Class.
+    '''
+
+    def schema(self):
+        ''' A dumpable version of the schema that we can store in the
+            database
+        '''
+        return (self.key, [(x, repr(y)) for x,y in self.properties.items()])
+
+    def enableJournalling(self):
+        '''Turn journalling on for this class
+        '''
+        self.do_journal = 1
+
+    def disableJournalling(self):
+        '''Turn journalling off for this class
+        '''
+        self.do_journal = 0
+
+    # Editing nodes:
+    def create(self, **propvalues):
+        ''' Create a new node of this class and return its id.
+
+        The keyword arguments in 'propvalues' map property names to values.
+
+        The values of arguments must be acceptable for the types of their
+        corresponding properties or a TypeError is raised.
+
+        If this class has a key property, it must be present and its value
+        must not collide with other key strings or a ValueError is raised.
+
+        Any other properties on this class that are missing from the
+        'propvalues' dictionary are set to None.
+
+        If an id in a link or multilink property does not refer to a valid
+        node, an IndexError is raised.
+        '''
+        self.fireAuditors('create', None, propvalues)
+        newid = self.create_inner(**propvalues)
+        self.fireReactors('create', newid, None)
+        return newid
+
+    def create_inner(self, **propvalues):
+        ''' Called by create, in-between the audit and react calls.
+        '''
+        if propvalues.has_key('id'):
+            raise KeyError, '"id" is reserved'
+
+        if self.db.journaltag is None:
+            raise DatabaseError, 'Database open read-only'
+
+        if propvalues.has_key('creator') or propvalues.has_key('actor') or \
+             propvalues.has_key('creation') or propvalues.has_key('activity'):
+            raise KeyError, '"creator", "actor", "creation" and '\
+                '"activity" are reserved'
+
+        # new node's id
+        newid = self.db.newid(self.classname)
+
+        # validate propvalues
+        num_re = re.compile('^\d+$')
+        for key, value in propvalues.items():
+            if key == self.key:
+                try:
+                    self.lookup(value)
+                except KeyError:
+                    pass
+                else:
+                    raise ValueError, 'node with key "%s" exists'%value
+
+            # try to handle this property
+            try:
+                prop = self.properties[key]
+            except KeyError:
+                raise KeyError, '"%s" has no property "%s"'%(self.classname,
+                    key)
+
+            if value is not None and isinstance(prop, Link):
+                if type(value) != type(''):
+                    raise ValueError, 'link value must be String'
+                link_class = self.properties[key].classname
+                # if it isn't a number, it's a key
+                if not num_re.match(value):
+                    try:
+                        value = self.db.classes[link_class].lookup(value)
+                    except (TypeError, KeyError):
+                        raise IndexError, 'new property "%s": %s not a %s'%(
+                            key, value, link_class)
+                elif not self.db.getclass(link_class).hasnode(value):
+                    raise IndexError, '%s has no node %s'%(link_class, value)
+
+                # save off the value
+                propvalues[key] = value
+
+                # register the link with the newly linked node
+                if self.do_journal and self.properties[key].do_journal:
+                    self.db.addjournal(link_class, value, 'link',
+                        (self.classname, newid, key))
+
+            elif isinstance(prop, Multilink):
+                if type(value) != type([]):
+                    raise TypeError, 'new property "%s" not a list of ids'%key
+
+                # clean up and validate the list of links
+                link_class = self.properties[key].classname
+                l = []
+                for entry in value:
+                    if type(entry) != type(''):
+                        raise ValueError, '"%s" multilink value (%r) '\
+                            'must contain Strings'%(key, value)
+                    # if it isn't a number, it's a key
+                    if not num_re.match(entry):
+                        try:
+                            entry = self.db.classes[link_class].lookup(entry)
+                        except (TypeError, KeyError):
+                            raise IndexError, 'new property "%s": %s not a %s'%(
+                                key, entry, self.properties[key].classname)
+                    l.append(entry)
+                value = l
+                propvalues[key] = value
+
+                # handle additions
+                for nodeid in value:
+                    if not self.db.getclass(link_class).hasnode(nodeid):
+                        raise IndexError, '%s has no node %s'%(link_class,
+                            nodeid)
+                    # register the link with the newly linked node
+                    if self.do_journal and self.properties[key].do_journal:
+                        self.db.addjournal(link_class, nodeid, 'link',
+                            (self.classname, newid, key))
+
+            elif isinstance(prop, String):
+                if type(value) != type('') and type(value) != type(u''):
+                    raise TypeError, 'new property "%s" not a string'%key
+                if prop.indexme:
+                    self.db.indexer.add_text((self.classname, newid, key),
+                        value)
+
+            elif isinstance(prop, Password):
+                if not isinstance(value, password.Password):
+                    raise TypeError, 'new property "%s" not a Password'%key
+
+            elif isinstance(prop, Date):
+                if value is not None and not isinstance(value, date.Date):
+                    raise TypeError, 'new property "%s" not a Date'%key
+
+            elif isinstance(prop, Interval):
+                if value is not None and not isinstance(value, date.Interval):
+                    raise TypeError, 'new property "%s" not an Interval'%key
+
+            elif value is not None and isinstance(prop, Number):
+                try:
+                    float(value)
+                except ValueError:
+                    raise TypeError, 'new property "%s" not numeric'%key
+
+            elif value is not None and isinstance(prop, Boolean):
+                try:
+                    int(value)
+                except ValueError:
+                    raise TypeError, 'new property "%s" not boolean'%key
+
+        # make sure there's data where there needs to be
+        for key, prop in self.properties.items():
+            if propvalues.has_key(key):
+                continue
+            if key == self.key:
+                raise ValueError, 'key property "%s" is required'%key
+            if isinstance(prop, Multilink):
+                propvalues[key] = []
+            else:
+                propvalues[key] = None
+
+        # done
+        self.db.addnode(self.classname, newid, propvalues)
+        if self.do_journal:
+            self.db.addjournal(self.classname, newid, ''"create", {})
+
+        # XXX numeric ids
+        return str(newid)
+
+    def get(self, nodeid, propname, default=_marker, cache=1):
+        '''Get the value of a property on an existing node of this class.
+
+        'nodeid' must be the id of an existing node of this class or an
+        IndexError is raised.  'propname' must be the name of a property
+        of this class or a KeyError is raised.
+
+        'cache' exists for backwards compatibility, and is not used.
+        '''
+        if propname == 'id':
+            return nodeid
+
+        # get the node's dict
+        d = self.db.getnode(self.classname, nodeid)
+
+        if propname == 'creation':
+            if d.has_key('creation'):
+                return d['creation']
+            else:
+                return date.Date()
+        if propname == 'activity':
+            if d.has_key('activity'):
+                return d['activity']
+            else:
+                return date.Date()
+        if propname == 'creator':
+            if d.has_key('creator'):
+                return d['creator']
+            else:
+                return self.db.getuid()
+        if propname == 'actor':
+            if d.has_key('actor'):
+                return d['actor']
+            else:
+                return self.db.getuid()
+
+        # get the property (raises KeyErorr if invalid)
+        prop = self.properties[propname]
+
+        # XXX may it be that propname is valid property name
+        #    (above error is not raised) and not d.has_key(propname)???
+        if (not d.has_key(propname)) or (d[propname] is None):
+            if default is _marker:
+                if isinstance(prop, Multilink):
+                    return []
+                else:
+                    return None
+            else:
+                return default
+
+        # don't pass our list to other code
+        if isinstance(prop, Multilink):
+            return d[propname][:]
+
+        return d[propname]
+
+    def set(self, nodeid, **propvalues):
+        '''Modify a property on an existing node of this class.
+
+        'nodeid' must be the id of an existing node of this class or an
+        IndexError is raised.
+
+        Each key in 'propvalues' must be the name of a property of this
+        class or a KeyError is raised.
+
+        All values in 'propvalues' must be acceptable types for their
+        corresponding properties or a TypeError is raised.
+
+        If the value of the key property is set, it must not collide with
+        other key strings or a ValueError is raised.
+
+        If the value of a Link or Multilink property contains an invalid
+        node id, a ValueError is raised.
+        '''
+        self.fireAuditors('set', nodeid, propvalues)
+        oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid))
+        propvalues = self.set_inner(nodeid, **propvalues)
+        self.fireReactors('set', nodeid, oldvalues)
+        return propvalues
+
+    def set_inner(self, nodeid, **propvalues):
+        ''' Called by set, in-between the audit and react calls.
+        '''
+        if not propvalues:
+            return propvalues
+
+        if propvalues.has_key('creation') or propvalues.has_key('creator') or \
+                propvalues.has_key('actor') or propvalues.has_key('activity'):
+            raise KeyError, '"creation", "creator", "actor" and '\
+                '"activity" are reserved'
+
+        if propvalues.has_key('id'):
+            raise KeyError, '"id" is reserved'
+
+        if self.db.journaltag is None:
+            raise DatabaseError, 'Database open read-only'
+
+        node = self.db.getnode(self.classname, nodeid)
+        if self.is_retired(nodeid):
+            raise IndexError, 'Requested item is retired'
+        num_re = re.compile('^\d+$')
+
+        # make a copy of the values dictionary - we'll modify the contents
+        propvalues = propvalues.copy()
+
+        # if the journal value is to be different, store it in here
+        journalvalues = {}
+
+        # remember the add/remove stuff for multilinks, making it easier
+        # for the Database layer to do its stuff
+        multilink_changes = {}
+
+        for propname, value in propvalues.items():
+            # check to make sure we're not duplicating an existing key
+            if propname == self.key and node[propname] != value:
+                try:
+                    self.lookup(value)
+                except KeyError:
+                    pass
+                else:
+                    raise ValueError, 'node with key "%s" exists'%value
+
+            # this will raise the KeyError if the property isn't valid
+            # ... we don't use getprops() here because we only care about
+            # the writeable properties.
+            try:
+                prop = self.properties[propname]
+            except KeyError:
+                raise KeyError, '"%s" has no property named "%s"'%(
+                    self.classname, propname)
+
+            # if the value's the same as the existing value, no sense in
+            # doing anything
+            current = node.get(propname, None)
+            if value == current:
+                del propvalues[propname]
+                continue
+            journalvalues[propname] = current
+
+            # do stuff based on the prop type
+            if isinstance(prop, Link):
+                link_class = prop.classname
+                # if it isn't a number, it's a key
+                if value is not None and not isinstance(value, type('')):
+                    raise ValueError, 'property "%s" link value be a string'%(
+                        propname)
+                if isinstance(value, type('')) and not num_re.match(value):
+                    try:
+                        value = self.db.classes[link_class].lookup(value)
+                    except (TypeError, KeyError):
+                        raise IndexError, 'new property "%s": %s not a %s'%(
+                            propname, value, prop.classname)
+
+                if (value is not None and
+                        not self.db.getclass(link_class).hasnode(value)):
+                    raise IndexError, '%s has no node %s'%(link_class, value)
+
+                if self.do_journal and prop.do_journal:
+                    # register the unlink with the old linked node
+                    if node[propname] is not None:
+                        self.db.addjournal(link_class, node[propname],
+                            ''"unlink", (self.classname, nodeid, propname))
+
+                    # register the link with the newly linked node
+                    if value is not None:
+                        self.db.addjournal(link_class, value, ''"link",
+                            (self.classname, nodeid, propname))
+
+            elif isinstance(prop, Multilink):
+                if type(value) != type([]):
+                    raise TypeError, 'new property "%s" not a list of'\
+                        ' ids'%propname
+                link_class = self.properties[propname].classname
+                l = []
+                for entry in value:
+                    # if it isn't a number, it's a key
+                    if type(entry) != type(''):
+                        raise ValueError, 'new property "%s" link value ' \
+                            'must be a string'%propname
+                    if not num_re.match(entry):
+                        try:
+                            entry = self.db.classes[link_class].lookup(entry)
+                        except (TypeError, KeyError):
+                            raise IndexError, 'new property "%s": %s not a %s'%(
+                                propname, entry,
+                                self.properties[propname].classname)
+                    l.append(entry)
+                value = l
+                propvalues[propname] = value
+
+                # figure the journal entry for this property
+                add = []
+                remove = []
+
+                # handle removals
+                if node.has_key(propname):
+                    l = node[propname]
+                else:
+                    l = []
+                for id in l[:]:
+                    if id in value:
+                        continue
+                    # register the unlink with the old linked node
+                    if self.do_journal and self.properties[propname].do_journal:
+                        self.db.addjournal(link_class, id, 'unlink',
+                            (self.classname, nodeid, propname))
+                    l.remove(id)
+                    remove.append(id)
+
+                # handle additions
+                for id in value:
+                    if not self.db.getclass(link_class).hasnode(id):
+                        raise IndexError, '%s has no node %s'%(link_class, id)
+                    if id in l:
+                        continue
+                    # register the link with the newly linked node
+                    if self.do_journal and self.properties[propname].do_journal:
+                        self.db.addjournal(link_class, id, 'link',
+                            (self.classname, nodeid, propname))
+                    l.append(id)
+                    add.append(id)
+
+                # figure the journal entry
+                l = []
+                if add:
+                    l.append(('+', add))
+                if remove:
+                    l.append(('-', remove))
+                multilink_changes[propname] = (add, remove)
+                if l:
+                    journalvalues[propname] = tuple(l)
+
+            elif isinstance(prop, String):
+                if value is not None and type(value) != type('') and type(value) != type(u''):
+                    raise TypeError, 'new property "%s" not a string'%propname
+                if prop.indexme:
+                    if value is None: value = ''
+                    self.db.indexer.add_text((self.classname, nodeid, propname),
+                        value)
+
+            elif isinstance(prop, Password):
+                if not isinstance(value, password.Password):
+                    raise TypeError, 'new property "%s" not a Password'%propname
+                propvalues[propname] = value
+
+            elif value is not None and isinstance(prop, Date):
+                if not isinstance(value, date.Date):
+                    raise TypeError, 'new property "%s" not a Date'% propname
+                propvalues[propname] = value
+
+            elif value is not None and isinstance(prop, Interval):
+                if not isinstance(value, date.Interval):
+                    raise TypeError, 'new property "%s" not an '\
+                        'Interval'%propname
+                propvalues[propname] = value
+
+            elif value is not None and isinstance(prop, Number):
+                try:
+                    float(value)
+                except ValueError:
+                    raise TypeError, 'new property "%s" not numeric'%propname
+
+            elif value is not None and isinstance(prop, Boolean):
+                try:
+                    int(value)
+                except ValueError:
+                    raise TypeError, 'new property "%s" not boolean'%propname
+
+        # nothing to do?
+        if not propvalues:
+            return propvalues
+
+        # update the activity time
+        propvalues['activity'] = date.Date()
+        propvalues['actor'] = self.db.getuid()
+
+        # do the set
+        self.db.setnode(self.classname, nodeid, propvalues, multilink_changes)
+
+        # remove the activity props now they're handled
+        del propvalues['activity']
+        del propvalues['actor']
+
+        # journal the set
+        if self.do_journal:
+            self.db.addjournal(self.classname, nodeid, ''"set", journalvalues)
+
+        return propvalues
+
+    def retire(self, nodeid):
+        '''Retire a node.
+
+        The properties on the node remain available from the get() method,
+        and the node's id is never reused.
+
+        Retired nodes are not returned by the find(), list(), or lookup()
+        methods, and other nodes may reuse the values of their key properties.
+        '''
+        if self.db.journaltag is None:
+            raise DatabaseError, 'Database open read-only'
+
+        self.fireAuditors('retire', nodeid, None)
+
+        # use the arg for __retired__ to cope with any odd database type
+        # conversion (hello, sqlite)
+        sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
+            self.db.arg, self.db.arg)
+        self.db.sql(sql, (1, nodeid))
+        if self.do_journal:
+            self.db.addjournal(self.classname, nodeid, ''"retired", None)
+
+        self.fireReactors('retire', nodeid, None)
+
+    def restore(self, nodeid):
+        '''Restore a retired node.
+
+        Make node available for all operations like it was before retirement.
+        '''
+        if self.db.journaltag is None:
+            raise DatabaseError, 'Database open read-only'
+
+        node = self.db.getnode(self.classname, nodeid)
+        # check if key property was overrided
+        key = self.getkey()
+        try:
+            id = self.lookup(node[key])
+        except KeyError:
+            pass
+        else:
+            raise KeyError, "Key property (%s) of retired node clashes with \
+                existing one (%s)" % (key, node[key])
+
+        self.fireAuditors('restore', nodeid, None)
+        # use the arg for __retired__ to cope with any odd database type
+        # conversion (hello, sqlite)
+        sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
+            self.db.arg, self.db.arg)
+        self.db.sql(sql, (0, nodeid))
+        if self.do_journal:
+            self.db.addjournal(self.classname, nodeid, ''"restored", None)
+
+        self.fireReactors('restore', nodeid, None)
+
+    def is_retired(self, nodeid):
+        '''Return true if the node is rerired
+        '''
+        sql = 'select __retired__ from _%s where id=%s'%(self.classname,
+            self.db.arg)
+        self.db.sql(sql, (nodeid,))
+        return int(self.db.sql_fetchone()[0])
+
+    def destroy(self, nodeid):
+        '''Destroy a node.
+
+        WARNING: this method should never be used except in extremely rare
+                 situations where there could never be links to the node being
+                 deleted
+
+        WARNING: use retire() instead
+
+        WARNING: the properties of this node will not be available ever again
+
+        WARNING: really, use retire() instead
+
+        Well, I think that's enough warnings. This method exists mostly to
+        support the session storage of the cgi interface.
+
+        The node is completely removed from the hyperdb, including all journal
+        entries. It will no longer be available, and will generally break code
+        if there are any references to the node.
+        '''
+        if self.db.journaltag is None:
+            raise DatabaseError, 'Database open read-only'
+        self.db.destroynode(self.classname, nodeid)
+
+    def history(self, nodeid):
+        '''Retrieve the journal of edits on a particular node.
+
+        'nodeid' must be the id of an existing node of this class or an
+        IndexError is raised.
+
+        The returned list contains tuples of the form
+
+            (nodeid, date, tag, action, params)
+
+        'date' is a Timestamp object specifying the time of the change and
+        'tag' is the journaltag specified when the database was opened.
+        '''
+        if not self.do_journal:
+            raise ValueError, 'Journalling is disabled for this class'
+        return self.db.getjournal(self.classname, nodeid)
+
+    # Locating nodes:
+    def hasnode(self, nodeid):
+        '''Determine if the given nodeid actually exists
+        '''
+        return self.db.hasnode(self.classname, nodeid)
+
+    def setkey(self, propname):
+        '''Select a String property of this class to be the key property.
+
+        'propname' must be the name of a String property of this class or
+        None, or a TypeError is raised.  The values of the key property on
+        all existing nodes must be unique or a ValueError is raised.
+        '''
+        prop = self.getprops()[propname]
+        if not isinstance(prop, String):
+            raise TypeError, 'key properties must be String'
+        self.key = propname
+
+    def getkey(self):
+        '''Return the name of the key property for this class or None.'''
+        return self.key
+
+    def lookup(self, keyvalue):
+        '''Locate a particular node by its key property and return its id.
+
+        If this class has no key property, a TypeError is raised.  If the
+        'keyvalue' matches one of the values for the key property among
+        the nodes in this class, the matching node's id is returned;
+        otherwise a KeyError is raised.
+        '''
+        if not self.key:
+            raise TypeError, 'No key property set for class %s'%self.classname
+
+        # use the arg to handle any odd database type conversion (hello,
+        # sqlite)
+        sql = "select id from _%s where _%s=%s and __retired__ <> %s"%(
+            self.classname, self.key, self.db.arg, self.db.arg)
+        self.db.sql(sql, (keyvalue, 1))
+
+        # see if there was a result that's not retired
+        row = self.db.sql_fetchone()
+        if not row:
+            raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key,
+                keyvalue, self.classname)
+
+        # return the id
+        # XXX numeric ids
+        return str(row[0])
+
+    def find(self, **propspec):
+        '''Get the ids of nodes in this class which link to the given nodes.
+
+        'propspec' consists of keyword args propname=nodeid or
+                   propname={nodeid:1, }
+        'propname' must be the name of a property in this class, or a
+                   KeyError is raised.  That property must be a Link or
+                   Multilink property, or a TypeError is raised.
+
+        Any node in this class whose 'propname' property links to any of
+        the nodeids will be returned. Examples::
+
+            db.issue.find(messages='1')
+            db.issue.find(messages={'1':1,'3':1}, files={'7':1})
+        '''
+        # shortcut
+        if not propspec:
+            return []
+
+        # validate the args
+        props = self.getprops()
+        propspec = propspec.items()
+        for propname, nodeids in propspec:
+            # check the prop is OK
+            prop = props[propname]
+            if not isinstance(prop, Link) and not isinstance(prop, Multilink):
+                raise TypeError, "'%s' not a Link/Multilink property"%propname
+
+        # first, links
+        a = self.db.arg
+        allvalues = ()
+        sql = []
+        where = []
+        for prop, values in propspec:
+            if not isinstance(props[prop], hyperdb.Link):
+                continue
+            if type(values) is type({}) and len(values) == 1:
+                values = values.keys()[0]
+            if type(values) is type(''):
+                allvalues += (values,)
+                where.append('_%s = %s'%(prop, a))
+            elif values is None:
+                where.append('_%s is NULL'%prop)
+            else:
+                values = values.keys()
+                s = ''
+                if None in values:
+                    values.remove(None)
+                    s = '_%s is NULL or '%prop
+                allvalues += tuple(values)
+                s += '_%s in (%s)'%(prop, ','.join([a]*len(values)))
+                where.append('(' + s +')')
+        if where:
+            allvalues = (1, ) + allvalues
+            sql.append('''select id from _%s where  __retired__ <> %s
+                and %s'''%(self.classname, a, ' and '.join(where)))
+
+        # now multilinks
+        for prop, values in propspec:
+            if not isinstance(props[prop], hyperdb.Multilink):
+                continue
+            if not values:
+                continue
+            allvalues += (1, )
+            if type(values) is type(''):
+                allvalues += (values,)
+                s = a
+            else:
+                allvalues += tuple(values.keys())
+                s = ','.join([a]*len(values))
+            tn = '%s_%s'%(self.classname, prop)
+            sql.append('''select id from _%s, %s where  __retired__ <> %s
+                  and id = %s.nodeid and %s.linkid in (%s)'''%(self.classname,
+                  tn, a, tn, tn, s))
+
+        if not sql:
+            return []
+        sql = ' union '.join(sql)
+        self.db.sql(sql, allvalues)
+        # XXX numeric ids
+        l = [str(x[0]) for x in self.db.sql_fetchall()]
+        return l
+
+    def stringFind(self, **requirements):
+        '''Locate a particular node by matching a set of its String
+        properties in a caseless search.
+
+        If the property is not a String property, a TypeError is raised.
+
+        The return is a list of the id of all nodes that match.
+        '''
+        where = []
+        args = []
+        for propname in requirements.keys():
+            prop = self.properties[propname]
+            if not isinstance(prop, String):
+                raise TypeError, "'%s' not a String property"%propname
+            where.append(propname)
+            args.append(requirements[propname].lower())
+
+        # generate the where clause
+        s = ' and '.join(['lower(_%s)=%s'%(col, self.db.arg) for col in where])
+        sql = 'select id from _%s where %s and __retired__<>%s'%(
+            self.classname, s, self.db.arg)
+        args.append(1)
+        self.db.sql(sql, tuple(args))
+        # XXX numeric ids
+        l = [str(x[0]) for x in self.db.sql_fetchall()]
+        return l
+
+    def list(self):
+        ''' Return a list of the ids of the active nodes in this class.
+        '''
+        return self.getnodeids(retired=0)
+
+    def getnodeids(self, retired=None):
+        ''' Retrieve all the ids of the nodes for a particular Class.
+
+            Set retired=None to get all nodes. Otherwise it'll get all the
+            retired or non-retired nodes, depending on the flag.
+        '''
+        # flip the sense of the 'retired' flag if we don't want all of them
+        if retired is not None:
+            if retired:
+                args = (0, )
+            else:
+                args = (1, )
+            sql = 'select id from _%s where __retired__ <> %s'%(self.classname,
+                self.db.arg)
+        else:
+            args = ()
+            sql = 'select id from _%s'%self.classname
+        self.db.sql(sql, args)
+        # XXX numeric ids
+        ids = [str(x[0]) for x in self.db.cursor.fetchall()]
+        return ids
+
+    def filter(self, search_matches, filterspec, sort=(None,None),
+            group=(None,None)):
+        '''Return a list of the ids of the active nodes in this class that
+        match the 'filter' spec, sorted by the group spec and then the
+        sort spec
+
+        "filterspec" is {propname: value(s)}
+
+        "sort" and "group" are (dir, prop) where dir is '+', '-' or None
+        and prop is a prop name or None
+
+        "search_matches" is {nodeid: marker} or None
+
+        The filter must match all properties specificed. If the property
+        value to match is a list:
+
+        1. String properties must match all elements in the list, and
+        2. Other properties must match any of the elements in the list.
+        '''
+        # we can't match anything if search_matches is empty
+        if search_matches == {}:
+            return []
+
+        if __debug__:
+            start_t = time.time()
+
+        cn = self.classname
+
+        # vars to hold the components of the SQL statement
+        frum = []       # FROM clauses
+        loj = []        # LEFT OUTER JOIN clauses
+        where = []      # WHERE clauses
+        args = []       # *any* positional arguments
+        a = self.db.arg
+
+        # figure the WHERE clause from the filterspec
+        props = self.getprops()
+        mlfilt = 0      # are we joining with Multilink tables?
+        for k, v in filterspec.items():
+            propclass = props[k]
+            # now do other where clause stuff
+            if isinstance(propclass, Multilink):
+                mlfilt = 1
+                tn = '%s_%s'%(cn, k)
+                if v in ('-1', ['-1']):
+                    # only match rows that have count(linkid)=0 in the
+                    # corresponding multilink table)
+                    where.append('_%s.id not in (select nodeid from %s)'%(cn,
+                        tn))
+                elif isinstance(v, type([])):
+                    frum.append(tn)
+                    s = ','.join([a for x in v])
+                    where.append('_%s.id=%s.nodeid and %s.linkid in (%s)'%(cn,
+                        tn, tn, s))
+                    args = args + v
+                else:
+                    frum.append(tn)
+                    where.append('_%s.id=%s.nodeid and %s.linkid=%s'%(cn, tn,
+                        tn, a))
+                    args.append(v)
+            elif k == 'id':
+                if isinstance(v, type([])):
+                    s = ','.join([a for x in v])
+                    where.append('_%s.%s in (%s)'%(cn, k, s))
+                    args = args + v
+                else:
+                    where.append('_%s.%s=%s'%(cn, k, a))
+                    args.append(v)
+            elif isinstance(propclass, String):
+                if not isinstance(v, type([])):
+                    v = [v]
+
+                # Quote the bits in the string that need it and then embed
+                # in a "substring" search. Note - need to quote the '%' so
+                # they make it through the python layer happily
+                v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v]
+
+                # now add to the where clause
+                where.append('('
+                    +' and '.join(["_%s._%s LIKE '%s'"%(cn, k, s) for s in v])
+                    +')')
+                # note: args are embedded in the query string now
+            elif isinstance(propclass, Link):
+                if isinstance(v, type([])):
+                    d = {}
+                    for entry in v:
+                        if entry == '-1':
+                            entry = None
+                        d[entry] = entry
+                    l = []
+                    if d.has_key(None) or not d:
+                        del d[None]
+                        l.append('_%s._%s is NULL'%(cn, k))
+                    if d:
+                        v = d.keys()
+                        s = ','.join([a for x in v])
+                        l.append('(_%s._%s in (%s))'%(cn, k, s))
+                        args = args + v
+                    if l:
+                        where.append('(' + ' or '.join(l) +')')
+                else:
+                    if v in ('-1', None):
+                        v = None
+                        where.append('_%s._%s is NULL'%(cn, k))
+                    else:
+                        where.append('_%s._%s=%s'%(cn, k, a))
+                        args.append(v)
+            elif isinstance(propclass, Date):
+                dc = self.db.hyperdb_to_sql_value[hyperdb.Date]
+                if isinstance(v, type([])):
+                    s = ','.join([a for x in v])
+                    where.append('_%s._%s in (%s)'%(cn, k, s))
+                    args = args + [dc(date.Date(v)) for x in v]
+                else:
+                    try:
+                        # Try to filter on range of dates
+                        date_rng = propclass.range_from_raw(v, self.db)
+                        if date_rng.from_value:
+                            where.append('_%s._%s >= %s'%(cn, k, a))
+                            args.append(dc(date_rng.from_value))
+                        if date_rng.to_value:
+                            where.append('_%s._%s <= %s'%(cn, k, a))
+                            args.append(dc(date_rng.to_value))
+                    except ValueError:
+                        # If range creation fails - ignore that search parameter
+                        pass
+            elif isinstance(propclass, Interval):
+                # filter using the __<prop>_int__ column
+                if isinstance(v, type([])):
+                    s = ','.join([a for x in v])
+                    where.append('_%s.__%s_int__ in (%s)'%(cn, k, s))
+                    args = args + [date.Interval(x).as_seconds() for x in v]
+                else:
+                    try:
+                        # Try to filter on range of intervals
+                        date_rng = Range(v, date.Interval)
+                        if date_rng.from_value:
+                            where.append('_%s.__%s_int__ >= %s'%(cn, k, a))
+                            args.append(date_rng.from_value.as_seconds())
+                        if date_rng.to_value:
+                            where.append('_%s.__%s_int__ <= %s'%(cn, k, a))
+                            args.append(date_rng.to_value.as_seconds())
+                    except ValueError:
+                        # If range creation fails - ignore that search parameter
+                        pass
+            else:
+                if isinstance(v, type([])):
+                    s = ','.join([a for x in v])
+                    where.append('_%s._%s in (%s)'%(cn, k, s))
+                    args = args + v
+                else:
+                    where.append('_%s._%s=%s'%(cn, k, a))
+                    args.append(v)
+
+        # don't match retired nodes
+        where.append('_%s.__retired__ <> 1'%cn)
+
+        # add results of full text search
+        if search_matches is not None:
+            v = search_matches.keys()
+            s = ','.join([a for x in v])
+            where.append('_%s.id in (%s)'%(cn, s))
+            args = args + v
+
+        # sanity check: sorting *and* grouping on the same property?
+        if group[1] == sort[1]:
+            sort = (None, None)
+
+        # "grouping" is just the first-order sorting in the SQL fetch
+        orderby = []
+        ordercols = []
+        mlsort = []
+        rhsnum = 0
+        for sortby in group, sort:
+            sdir, prop = sortby
+            if sdir and prop:
+                if isinstance(props[prop], Multilink):
+                    mlsort.append(sortby)
+                    continue
+                elif isinstance(props[prop], Interval):
+                    # use the int column for sorting
+                    o = '__'+prop+'_int__'
+                    ordercols.append(o)
+                elif isinstance(props[prop], Link):
+                    # determine whether the linked Class has an order property
+                    lcn = props[prop].classname
+                    link = self.db.classes[lcn]
+                    o = '_%s._%s'%(cn, prop)
+                    op = link.orderprop()
+                    if op != 'id':
+                        tn = '_' + lcn
+                        rhs = 'rhs%s_'%rhsnum
+                        rhsnum += 1
+                        loj.append('LEFT OUTER JOIN %s as %s on %s=%s.id'%(
+                            tn, rhs, o, rhs))
+                        o = '%s._%s'%(rhs, op)
+                    ordercols.append(o)
+                elif prop == 'id':
+                    o = '_%s.id'%cn
+                else:
+                    o = '_%s._%s'%(cn, prop)
+                    ordercols.append(o)
+                if sdir == '-':
+                    o += ' desc'
+                orderby.append(o)
+
+        # construct the SQL
+        frum.append('_'+cn)
+        frum = ','.join(frum)
+        if where:
+            where = ' where ' + (' and '.join(where))
+        else:
+            where = ''
+        if mlfilt:
+            # we're joining tables on the id, so we will get dupes if we
+            # don't distinct()
+            cols = ['distinct(_%s.id)'%cn]
+        else:
+            cols = ['_%s.id'%cn]
+        if orderby:
+            cols = cols + ordercols
+            order = ' order by %s'%(','.join(orderby))
+        else:
+            order = ''
+        cols = ','.join(cols)
+        loj = ' '.join(loj)
+        sql = 'select %s from %s %s %s%s'%(cols, frum, loj, where, order)
+        args = tuple(args)
+        __traceback_info__ = (sql, args)
+        self.db.sql(sql, args)
+        l = self.db.sql_fetchall()
+
+        # return the IDs (the first column)
+        # XXX numeric ids
+        l = [str(row[0]) for row in l]
+
+        if not mlsort:
+            if __debug__:
+                self.db.stats['filtering'] += (time.time() - start_t)
+            return l
+
+        # ergh. someone wants to sort by a multilink.
+        r = []
+        for id in l:
+            m = []
+            for ml in mlsort:
+                m.append(self.get(id, ml[1]))
+            r.append((id, m))
+        i = 0
+        for sortby in mlsort:
+            def sortfun(a, b, dir=sortby[i], i=i):
+                if dir == '-':
+                    return cmp(b[1][i], a[1][i])
+                else:
+                    return cmp(a[1][i], b[1][i])
+            r.sort(sortfun)
+            i += 1
+        r = [i[0] for i in r]
+
+        if __debug__:
+            self.db.stats['filtering'] += (time.time() - start_t)
+
+        return r
+
+    def count(self):
+        '''Get the number of nodes in this class.
+
+        If the returned integer is 'numnodes', the ids of all the nodes
+        in this class run from 1 to numnodes, and numnodes+1 will be the
+        id of the next node to be created in this class.
+        '''
+        return self.db.countnodes(self.classname)
+
+    # Manipulating properties:
+    def getprops(self, protected=1):
+        '''Return a dictionary mapping property names to property objects.
+           If the "protected" flag is true, we include protected properties -
+           those which may not be modified.
+        '''
+        d = self.properties.copy()
+        if protected:
+            d['id'] = String()
+            d['creation'] = hyperdb.Date()
+            d['activity'] = hyperdb.Date()
+            d['creator'] = hyperdb.Link('user')
+            d['actor'] = hyperdb.Link('user')
+        return d
+
+    def addprop(self, **properties):
+        '''Add properties to this class.
+
+        The keyword arguments in 'properties' must map names to property
+        objects, or a TypeError is raised.  None of the keys in 'properties'
+        may collide with the names of existing properties, or a ValueError
+        is raised before any properties have been added.
+        '''
+        for key in properties.keys():
+            if self.properties.has_key(key):
+                raise ValueError, key
+        self.properties.update(properties)
+
+    def index(self, nodeid):
+        '''Add (or refresh) the node to search indexes
+        '''
+        # find all the String properties that have indexme
+        for prop, propclass in self.getprops().items():
+            if isinstance(propclass, String) and propclass.indexme:
+                self.db.indexer.add_text((self.classname, nodeid, prop),
+                    str(self.get(nodeid, prop)))
+
+    #
+    # import / export support
+    #
+    def export_list(self, propnames, nodeid):
+        ''' Export a node - generate a list of CSV-able data in the order
+            specified by propnames for the given node.
+        '''
+        properties = self.getprops()
+        l = []
+        for prop in propnames:
+            proptype = properties[prop]
+            value = self.get(nodeid, prop)
+            # "marshal" data where needed
+            if value is None:
+                pass
+            elif isinstance(proptype, hyperdb.Date):
+                value = value.get_tuple()
+            elif isinstance(proptype, hyperdb.Interval):
+                value = value.get_tuple()
+            elif isinstance(proptype, hyperdb.Password):
+                value = str(value)
+            l.append(repr(value))
+        l.append(repr(self.is_retired(nodeid)))
+        return l
+
+    def import_list(self, propnames, proplist):
+        ''' Import a node - all information including "id" is present and
+            should not be sanity checked. Triggers are not triggered. The
+            journal should be initialised using the "creator" and "created"
+            information.
+
+            Return the nodeid of the node imported.
+        '''
+        if self.db.journaltag is None:
+            raise DatabaseError, 'Database open read-only'
+        properties = self.getprops()
+
+        # make the new node's property map
+        d = {}
+        retire = 0
+        if not "id" in propnames:
+            newid = self.db.newid(self.classname)
+        else:
+            newid = eval(proplist[propnames.index("id")])
+        for i in range(len(propnames)):
+            # Use eval to reverse the repr() used to output the CSV
+            value = eval(proplist[i])
+
+            # Figure the property for this column
+            propname = propnames[i]
+
+            # "unmarshal" where necessary
+            if propname == 'id':
+                continue
+            elif propname == 'is retired':
+                # is the item retired?
+                if int(value):
+                    retire = 1
+                continue
+            elif value is None:
+                d[propname] = None
+                continue
+
+            prop = properties[propname]
+            if value is None:
+                # don't set Nones
+                continue
+            elif isinstance(prop, hyperdb.Date):
+                value = date.Date(value)
+            elif isinstance(prop, hyperdb.Interval):
+                value = date.Interval(value)
+            elif isinstance(prop, hyperdb.Password):
+                pwd = password.Password()
+                pwd.unpack(value)
+                value = pwd
+            elif isinstance(prop, String):
+                if isinstance(value, unicode):
+                    value = value.encode('utf8')
+                if not isinstance(value, str):
+                    raise TypeError, \
+                        'new property "%(propname)s" not a string: %(value)r' \
+                        % locals()
+                if prop.indexme:
+                    self.db.indexer.add_text((self.classname, newid, propname),
+                        value)
+            d[propname] = value
+
+        # get a new id if necessary
+        if newid is None:
+            newid = self.db.newid(self.classname)
+
+        # insert new node or update existing?
+        if not self.hasnode(newid):
+            self.db.addnode(self.classname, newid, d) # insert
+        else:
+            self.db.setnode(self.classname, newid, d) # update
+
+        # retire?
+        if retire:
+            # use the arg for __retired__ to cope with any odd database type
+            # conversion (hello, sqlite)
+            sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
+                self.db.arg, self.db.arg)
+            self.db.sql(sql, (1, newid))
+        return newid
+
+    def export_journals(self):
+        '''Export a class's journal - generate a list of lists of
+        CSV-able data:
+
+            nodeid, date, user, action, params
+
+        No heading here - the columns are fixed.
+        '''
+        properties = self.getprops()
+        r = []
+        for nodeid in self.getnodeids():
+            for nodeid, date, user, action, params in self.history(nodeid):
+                date = date.get_tuple()
+                if action == 'set':
+                    export_data = {}
+                    for propname, value in params.items():
+                        if not properties.has_key(propname):
+                            # property no longer in the schema
+                            continue
+
+                        prop = properties[propname]
+                        # make sure the params are eval()'able
+                        if value is None:
+                            pass
+                        elif isinstance(prop, Date):
+                            value = value.get_tuple()
+                        elif isinstance(prop, Interval):
+                            value = value.get_tuple()
+                        elif isinstance(prop, Password):
+                            value = str(value)
+                        export_data[propname] = value
+                    params = export_data
+                l = [nodeid, date, user, action, params]
+                r.append(map(repr, l))
+        return r
+
+    def import_journals(self, entries):
+        '''Import a class's journal.
+
+        Uses setjournal() to set the journal for each item.'''
+        properties = self.getprops()
+        d = {}
+        for l in entries:
+            l = map(eval, l)
+            nodeid, jdate, user, action, params = l
+            r = d.setdefault(nodeid, [])
+            if action == 'set':
+                for propname, value in params.items():
+                    prop = properties[propname]
+                    if value is None:
+                        pass
+                    elif isinstance(prop, Date):
+                        value = date.Date(value)
+                    elif isinstance(prop, Interval):
+                        value = date.Interval(value)
+                    elif isinstance(prop, Password):
+                        pwd = password.Password()
+                        pwd.unpack(value)
+                        value = pwd
+                    params[propname] = value
+            r.append((nodeid, date.Date(jdate), user, action, params))
+
+        for nodeid, l in d.items():
+            self.db.setjournal(self.classname, nodeid, l)
+
+class FileClass(hyperdb.FileClass, Class):
+    '''This class defines a large chunk of data. To support this, it has a
+       mandatory String property "content" which is typically saved off
+       externally to the hyperdb.
+
+       The default MIME type of this data is defined by the
+       "default_mime_type" class attribute, which may be overridden by each
+       node if the class defines a "type" String property.
+    '''
+    def __init__(self, db, classname, **properties):
+        '''The newly-created class automatically includes the "content"
+        and "type" properties.
+        '''
+        if not properties.has_key('content'):
+            properties['content'] = hyperdb.String(indexme='yes')
+        if not properties.has_key('type'):
+            properties['type'] = hyperdb.String()
+        Class.__init__(self, db, classname, **properties)
+
+    def create(self, **propvalues):
+        ''' snaffle the file propvalue and store in a file
+        '''
+        # we need to fire the auditors now, or the content property won't
+        # be in propvalues for the auditors to play with
+        self.fireAuditors('create', None, propvalues)
+
+        # now remove the content property so it's not stored in the db
+        content = propvalues['content']
+        del propvalues['content']
+
+        # do the database create
+        newid = self.create_inner(**propvalues)
+
+        # figure the mime type
+        mime_type = propvalues.get('type', self.default_mime_type)
+
+        # and index!
+        if self.properties['content'].indexme:
+            self.db.indexer.add_text((self.classname, newid, 'content'),
+                content, mime_type)
+
+        # fire reactors
+        self.fireReactors('create', newid, None)
+
+        # store off the content as a file
+        self.db.storefile(self.classname, newid, None, content)
+        return newid
+
+    def get(self, nodeid, propname, default=_marker, cache=1):
+        ''' Trap the content propname and get it from the file
+
+        'cache' exists for backwards compatibility, and is not used.
+        '''
+        poss_msg = 'Possibly a access right configuration problem.'
+        if propname == 'content':
+            try:
+                return self.db.getfile(self.classname, nodeid, None)
+            except IOError, (strerror):
+                # BUG: by catching this we donot see an error in the log.
+                return 'ERROR reading file: %s%s\n%s\n%s'%(
+                        self.classname, nodeid, poss_msg, strerror)
+        if default is not _marker:
+            return Class.get(self, nodeid, propname, default)
+        else:
+            return Class.get(self, nodeid, propname)
+
+    def getprops(self, protected=1):
+        '''In addition to the actual properties on the node, these methods
+        provide the "content" property. If the "protected" flag is true,
+        we include protected properties - those which may not be
+        modified.
+
+        Note that the content prop is indexed separately, hence no indexme.
+        '''
+        d = Class.getprops(self, protected=protected).copy()
+        d['content'] = hyperdb.String()
+        return d
+
+    def set(self, itemid, **propvalues):
+        ''' Snarf the "content" propvalue and update it in a file
+        '''
+        self.fireAuditors('set', itemid, propvalues)
+        oldvalues = copy.deepcopy(self.db.getnode(self.classname, itemid))
+
+        # now remove the content property so it's not stored in the db
+        content = None
+        if propvalues.has_key('content'):
+            content = propvalues['content']
+            del propvalues['content']
+
+        # do the database create
+        propvalues = self.set_inner(itemid, **propvalues)
+
+        # do content?
+        if content:
+            # store and possibly index
+            self.db.storefile(self.classname, itemid, None, content)
+            if self.properties['content'].indexme:
+                mime_type = self.get(itemid, 'type', self.default_mime_type)
+                self.db.indexer.add_text((self.classname, itemid, 'content'),
+                    content, mime_type)
+            propvalues['content'] = content
+
+        # fire reactors
+        self.fireReactors('set', itemid, oldvalues)
+        return propvalues
+
+    def index(self, nodeid):
+        ''' Add (or refresh) the node to search indexes.
+
+        Use the content-type property for the content property.
+        '''
+        # find all the String properties that have indexme
+        for prop, propclass in self.getprops().items():
+            if prop == 'content' and propclass.indexme:
+                mime_type = self.get(nodeid, 'type', self.default_mime_type)
+                self.db.indexer.add_text((self.classname, nodeid, 'content'),
+                    str(self.get(nodeid, 'content')), mime_type)
+            elif isinstance(propclass, hyperdb.String) and propclass.indexme:
+                # index them under (classname, nodeid, property)
+                try:
+                    value = str(self.get(nodeid, prop))
+                except IndexError:
+                    # node has been destroyed
+                    continue
+                self.db.indexer.add_text((self.classname, nodeid, prop), value)
+
+# XXX deviation from spec - was called ItemClass
+class IssueClass(Class, roundupdb.IssueClass):
+    # Overridden methods:
+    def __init__(self, db, classname, **properties):
+        '''The newly-created class automatically includes the "messages",
+        "files", "nosy", and "superseder" properties.  If the 'properties'
+        dictionary attempts to specify any of these properties or a
+        "creation", "creator", "activity" or "actor" property, a ValueError
+        is raised.
+        '''
+        if not properties.has_key('title'):
+            properties['title'] = hyperdb.String(indexme='yes')
+        if not properties.has_key('messages'):
+            properties['messages'] = hyperdb.Multilink("msg")
+        if not properties.has_key('files'):
+            properties['files'] = hyperdb.Multilink("file")
+        if not properties.has_key('nosy'):
+            # note: journalling is turned off as it really just wastes
+            # space. this behaviour may be overridden in an instance
+            properties['nosy'] = hyperdb.Multilink("user", do_journal="no")
+        if not properties.has_key('superseder'):
+            properties['superseder'] = hyperdb.Multilink(classname)
+        Class.__init__(self, db, classname, **properties)
+
+# vim: set et sts=4 sw=4 :

Added: tracker/vendor/roundup/current/roundup/backends/sessions_dbm.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/backends/sessions_dbm.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,157 @@
+#$Id: sessions_dbm.py,v 1.7 2006/04/27 04:59:37 richard Exp $
+"""This module defines a very basic store that's used by the CGI interface
+to store session and one-time-key information.
+
+Yes, it's called "sessions" - because originally it only defined a session
+class. It's now also used for One Time Key handling too.
+"""
+__docformat__ = 'restructuredtext'
+
+import anydbm, whichdb, os, marshal, time
+
+class BasicDatabase:
+    ''' Provide a nice encapsulation of an anydbm store.
+
+        Keys are id strings, values are automatically marshalled data.
+    '''
+    _db_type = None
+
+    def __init__(self, db):
+        self.config = db.config
+        self.dir = db.config.DATABASE
+        os.umask(db.config.UMASK)
+
+    def exists(self, infoid):
+        db = self.opendb('c')
+        try:
+            return db.has_key(infoid)
+        finally:
+            db.close()
+
+    def clear(self):
+        path = os.path.join(self.dir, self.name)
+        if os.path.exists(path):
+            os.remove(path)
+        elif os.path.exists(path+'.db'):    # dbm appends .db
+            os.remove(path+'.db')
+
+    def cache_db_type(self, path):
+        ''' determine which DB wrote the class file, and cache it as an
+            attribute of __class__ (to allow for subclassed DBs to be
+            different sorts)
+        '''
+        db_type = ''
+        if os.path.exists(path):
+            db_type = whichdb.whichdb(path)
+            if not db_type:
+                raise hyperdb.DatabaseError, "Couldn't identify database type"
+        elif os.path.exists(path+'.db'):
+            # if the path ends in '.db', it's a dbm database, whether
+            # anydbm says it's dbhash or not!
+            db_type = 'dbm'
+        self.__class__._db_type = db_type
+
+    _marker = []
+    def get(self, infoid, value, default=_marker):
+        db = self.opendb('c')
+        try:
+            if db.has_key(infoid):
+                values = marshal.loads(db[infoid])
+            else:
+                if default != self._marker:
+                    return default
+                raise KeyError, 'No such %s "%s"'%(self.name, infoid)
+            return values.get(value, None)
+        finally:
+            db.close()
+
+    def getall(self, infoid):
+        db = self.opendb('c')
+        try:
+            try:
+                d = marshal.loads(db[infoid])
+                del d['__timestamp']
+                return d
+            except KeyError:
+                raise KeyError, 'No such %s "%s"'%(self.name, infoid)
+        finally:
+            db.close()
+
+    def set(self, infoid, **newvalues):
+        db = self.opendb('c')
+        try:
+            if db.has_key(infoid):
+                values = marshal.loads(db[infoid])
+            else:
+                values = {'__timestamp': time.time()}
+            values.update(newvalues)
+            db[infoid] = marshal.dumps(values)
+        finally:
+            db.close()
+
+    def list(self):
+        db = self.opendb('r')
+        try:
+            return db.keys()
+        finally:
+            db.close()
+
+    def destroy(self, infoid):
+        db = self.opendb('c')
+        try:
+            if db.has_key(infoid):
+                del db[infoid]
+        finally:
+            db.close()
+
+    def opendb(self, mode):
+        '''Low-level database opener that gets around anydbm/dbm
+           eccentricities.
+        '''
+        # figure the class db type
+        path = os.path.join(os.getcwd(), self.dir, self.name)
+        if self._db_type is None:
+            self.cache_db_type(path)
+
+        db_type = self._db_type
+
+        # new database? let anydbm pick the best dbm
+        if not db_type:
+            return anydbm.open(path, 'c')
+
+        # open the database with the correct module
+        dbm = __import__(db_type)
+        return dbm.open(path, mode)
+
+    def commit(self):
+        pass
+
+    def close(self):
+        pass
+
+    def updateTimestamp(self, sessid):
+        ''' don't update every hit - once a minute should be OK '''
+        sess = self.get(sessid, '__timestamp', None)
+        now = time.time()
+        if sess is None or now > sess + 60:
+            self.set(sessid, __timestamp=now)
+
+    def clean(self, now):
+        """Age sessions, remove when they haven't been used for a week.
+        """
+        week = 60*60*24*7
+        for sessid in self.list():
+            sess = self.get(sessid, '__timestamp', None)
+            if sess is None:
+                sess=time.time()
+                self.updateTimestamp(sessid)
+            interval = now - sess
+            if interval > week:
+                self.destroy(sessid)
+
+class Sessions(BasicDatabase):
+    name = 'sessions'
+
+class OneTimeKeys(BasicDatabase):
+    name = 'otks'
+

Added: tracker/vendor/roundup/current/roundup/backends/sessions_rdbms.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/backends/sessions_rdbms.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,99 @@
+#$Id: sessions_rdbms.py,v 1.4 2006/04/27 04:03:11 richard Exp $
+"""This module defines a very basic store that's used by the CGI interface
+to store session and one-time-key information.
+
+Yes, it's called "sessions" - because originally it only defined a session
+class. It's now also used for One Time Key handling too.
+"""
+__docformat__ = 'restructuredtext'
+
+import os, time
+
+class BasicDatabase:
+    ''' Provide a nice encapsulation of an RDBMS table.
+
+        Keys are id strings, values are automatically marshalled data.
+    '''
+    def __init__(self, db):
+        self.db = db
+        self.cursor = self.db.cursor
+
+    def clear(self):
+        self.cursor.execute('delete from %ss'%self.name)
+
+    def exists(self, infoid):
+        n = self.name
+        self.cursor.execute('select count(*) from %ss where %s_key=%s'%(n,
+            n, self.db.arg), (infoid,))
+        return int(self.cursor.fetchone()[0])
+
+    _marker = []
+    def get(self, infoid, value, default=_marker):
+        n = self.name
+        self.cursor.execute('select %s_value from %ss where %s_key=%s'%(n,
+            n, n, self.db.arg), (infoid,))
+        res = self.cursor.fetchone()
+        if not res:
+            if default != self._marker:
+                return default
+            raise KeyError, 'No such %s "%s"'%(self.name, infoid)
+        values = eval(res[0])
+        return values.get(value, None)
+
+    def getall(self, infoid):
+        n = self.name
+        self.cursor.execute('select %s_value from %ss where %s_key=%s'%(n,
+            n, n, self.db.arg), (infoid,))
+        res = self.cursor.fetchone()
+        if not res:
+            raise KeyError, 'No such %s "%s"'%(self.name, infoid)
+        return eval(res[0])
+
+    def set(self, infoid, **newvalues):
+        c = self.cursor
+        n = self.name
+        a = self.db.arg
+        c.execute('select %s_value from %ss where %s_key=%s'%(n, n, n, a),
+            (infoid,))
+        res = c.fetchone()
+        if res:
+            values = eval(res[0])
+        else:
+            values = {}
+        values.update(newvalues)
+
+        if res:
+            sql = 'update %ss set %s_value=%s where %s_key=%s'%(n, n,
+                a, n, a)
+            args = (repr(values), infoid)
+        else:
+            sql = 'insert into %ss (%s_key, %s_time, %s_value) '\
+                'values (%s, %s, %s)'%(n, n, n, n, a, a, a)
+            args = (infoid, time.time(), repr(values))
+        c.execute(sql, args)
+
+    def destroy(self, infoid):
+        self.cursor.execute('delete from %ss where %s_key=%s'%(self.name,
+            self.name, self.db.arg), (infoid,))
+
+    def updateTimestamp(self, infoid):
+        ''' don't update every hit - once a minute should be OK '''
+        now = time.time()
+        self.cursor.execute('''update %ss set %s_time=%s where %s_key=%s
+            and %s_time < %s'''%(self.name, self.name, self.db.arg,
+            self.name, self.db.arg, self.name, self.db.arg),
+            (now, infoid, now-60))
+
+    def clean(self, now):
+        """Age sessions, remove when they haven't been used for a week.
+        """
+        old = now - 60*60*24*7
+        self.cursor.execute('delete from %ss where %s_time < %s'%(self.name,
+            self.name, self.db.arg), (old, ))
+
+class Sessions(BasicDatabase):
+    name = 'session'
+
+class OneTimeKeys(BasicDatabase):
+    name = 'otk'
+

Added: tracker/vendor/roundup/current/roundup/backends/tsearch2_setup.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/backends/tsearch2_setup.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,737 @@
+#$Id: tsearch2_setup.py,v 1.2 2005/01/08 11:25:23 jlgijsbers Exp $
+
+# All the SQL in this module is taken from the tsearch2 module in the contrib
+# tree of PostgreSQL 7.4.6. PostgreSQL, and this code, has the following
+# license:
+#
+# PostgreSQL Data Base Management System
+# (formerly known as Postgres, then as Postgres95).
+#
+# Portions Copyright (c) 1996-2003, The PostgreSQL Global Development Group
+#
+# Portions Copyright (c) 1994, The Regents of the University of California
+#
+# Permission to use, copy, modify, and distribute this software and its
+# documentation for any purpose, without fee, and without a written agreement
+# is hereby granted, provided that the above copyright notice and this
+# paragraph and the following two paragraphs appear in all copies.
+#
+# IN NO EVENT SHALL THE UNIVERSITY OF CALIFORNIA BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING
+# LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS
+# DOCUMENTATION, EVEN IF THE UNIVERSITY OF CALIFORNIA HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# THE UNIVERSITY OF CALIFORNIA SPECIFICALLY DISCLAIMS ANY WARRANTIES,
+# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
+# AND FITNESS FOR A PARTICULAR PURPOSE.  THE SOFTWARE PROVIDED HEREUNDER IS
+# ON AN "AS IS" BASIS, AND THE UNIVERSITY OF CALIFORNIA HAS NO OBLIGATIONS TO
+# PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+
+tsearch_sql = """ -- Adjust this setting to control where the objects get CREATEd.
+SET search_path = public;
+
+--dict conf
+CREATE TABLE pg_ts_dict (
+	dict_name	text not null primary key,
+	dict_init	oid,
+	dict_initoption	text,
+	dict_lexize	oid not null,
+	dict_comment	text
+) with oids;
+
+--dict interface
+CREATE FUNCTION lexize(oid, text) 
+	returns _text
+	as '$libdir/tsearch2'
+	language 'C'
+	with (isstrict);
+
+CREATE FUNCTION lexize(text, text)
+        returns _text
+        as '$libdir/tsearch2', 'lexize_byname'
+        language 'C'
+        with (isstrict);
+
+CREATE FUNCTION lexize(text)
+        returns _text
+        as '$libdir/tsearch2', 'lexize_bycurrent'
+        language 'C'
+        with (isstrict);
+
+CREATE FUNCTION set_curdict(int)
+	returns void
+	as '$libdir/tsearch2'
+	language 'C'
+	with (isstrict);
+
+CREATE FUNCTION set_curdict(text)
+	returns void
+	as '$libdir/tsearch2', 'set_curdict_byname'
+	language 'C'
+	with (isstrict);
+
+--built-in dictionaries
+CREATE FUNCTION dex_init(text)
+	returns internal
+	as '$libdir/tsearch2' 
+	language 'C';
+
+CREATE FUNCTION dex_lexize(internal,internal,int4)
+	returns internal
+	as '$libdir/tsearch2'
+	language 'C'
+	with (isstrict);
+
+insert into pg_ts_dict select 
+	'simple', 
+	(select oid from pg_proc where proname='dex_init'),
+	null,
+	(select oid from pg_proc where proname='dex_lexize'),
+	'Simple example of dictionary.'
+;
+	 
+CREATE FUNCTION snb_en_init(text)
+	returns internal
+	as '$libdir/tsearch2' 
+	language 'C';
+
+CREATE FUNCTION snb_lexize(internal,internal,int4)
+	returns internal
+	as '$libdir/tsearch2'
+	language 'C'
+	with (isstrict);
+
+insert into pg_ts_dict select 
+	'en_stem', 
+	(select oid from pg_proc where proname='snb_en_init'),
+	'/usr/share/postgresql/contrib/english.stop',
+	(select oid from pg_proc where proname='snb_lexize'),
+	'English Stemmer. Snowball.'
+;
+
+CREATE FUNCTION snb_ru_init(text)
+	returns internal
+	as '$libdir/tsearch2' 
+	language 'C';
+
+insert into pg_ts_dict select 
+	'ru_stem', 
+	(select oid from pg_proc where proname='snb_ru_init'),
+	'/usr/share/postgresql/contrib/russian.stop',
+	(select oid from pg_proc where proname='snb_lexize'),
+	'Russian Stemmer. Snowball.'
+;
+	 
+CREATE FUNCTION spell_init(text)
+	returns internal
+	as '$libdir/tsearch2' 
+	language 'C';
+
+CREATE FUNCTION spell_lexize(internal,internal,int4)
+	returns internal
+	as '$libdir/tsearch2'
+	language 'C'
+	with (isstrict);
+
+insert into pg_ts_dict select 
+	'ispell_template', 
+	(select oid from pg_proc where proname='spell_init'),
+	null,
+	(select oid from pg_proc where proname='spell_lexize'),
+	'ISpell interface. Must have .dict and .aff files'
+;
+
+CREATE FUNCTION syn_init(text)
+	returns internal
+	as '$libdir/tsearch2' 
+	language 'C';
+
+CREATE FUNCTION syn_lexize(internal,internal,int4)
+	returns internal
+	as '$libdir/tsearch2'
+	language 'C'
+	with (isstrict);
+
+insert into pg_ts_dict select 
+	'synonym', 
+	(select oid from pg_proc where proname='syn_init'),
+	null,
+	(select oid from pg_proc where proname='syn_lexize'),
+	'Example of synonym dictionary'
+;
+
+--dict conf
+CREATE TABLE pg_ts_parser (
+	prs_name	text not null primary key,
+	prs_start	oid not null,
+	prs_nexttoken	oid not null,
+	prs_end		oid not null,
+	prs_headline	oid not null,
+	prs_lextype	oid not null,
+	prs_comment	text
+) with oids;
+
+--sql-level interface
+CREATE TYPE tokentype 
+	as (tokid int4, alias text, descr text); 
+
+CREATE FUNCTION token_type(int4)
+	returns setof tokentype
+	as '$libdir/tsearch2'
+	language 'C'
+	with (isstrict);
+
+CREATE FUNCTION token_type(text)
+	returns setof tokentype
+	as '$libdir/tsearch2', 'token_type_byname'
+	language 'C'
+	with (isstrict);
+
+CREATE FUNCTION token_type()
+	returns setof tokentype
+	as '$libdir/tsearch2', 'token_type_current'
+	language 'C'
+	with (isstrict);
+
+CREATE FUNCTION set_curprs(int)
+	returns void
+	as '$libdir/tsearch2'
+	language 'C'
+	with (isstrict);
+
+CREATE FUNCTION set_curprs(text)
+	returns void
+	as '$libdir/tsearch2', 'set_curprs_byname'
+	language 'C'
+	with (isstrict);
+
+CREATE TYPE tokenout 
+	as (tokid int4, token text);
+
+CREATE FUNCTION parse(oid,text)
+	returns setof tokenout
+	as '$libdir/tsearch2'
+	language 'C'
+	with (isstrict);
+ 
+CREATE FUNCTION parse(text,text)
+	returns setof tokenout
+	as '$libdir/tsearch2', 'parse_byname'
+	language 'C'
+	with (isstrict);
+ 
+CREATE FUNCTION parse(text)
+	returns setof tokenout
+	as '$libdir/tsearch2', 'parse_current'
+	language 'C'
+	with (isstrict);
+ 
+--default parser
+CREATE FUNCTION prsd_start(internal,int4)
+	returns internal
+	as '$libdir/tsearch2'
+	language 'C';
+
+CREATE FUNCTION prsd_getlexeme(internal,internal,internal)
+	returns int4
+	as '$libdir/tsearch2'
+	language 'C';
+
+CREATE FUNCTION prsd_end(internal)
+	returns void
+	as '$libdir/tsearch2'
+	language 'C';
+
+CREATE FUNCTION prsd_lextype(internal)
+	returns internal
+	as '$libdir/tsearch2'
+	language 'C';
+
+CREATE FUNCTION prsd_headline(internal,internal,internal)
+	returns internal
+	as '$libdir/tsearch2'
+	language 'C';
+
+insert into pg_ts_parser select
+	'default',
+	(select oid from pg_proc where proname='prsd_start'),	
+	(select oid from pg_proc where proname='prsd_getlexeme'),	
+	(select oid from pg_proc where proname='prsd_end'),	
+	(select oid from pg_proc where proname='prsd_headline'),
+	(select oid from pg_proc where proname='prsd_lextype'),
+	'Parser from OpenFTS v0.34'
+;	
+
+--tsearch config
+
+CREATE TABLE pg_ts_cfg (
+	ts_name		text not null primary key,
+	prs_name	text not null,
+	locale		text
+) with oids;
+
+CREATE TABLE pg_ts_cfgmap (
+	ts_name		text not null,
+	tok_alias	text not null,
+	dict_name	text[],
+	primary key (ts_name,tok_alias)
+) with oids;
+
+CREATE FUNCTION set_curcfg(int)
+	returns void
+	as '$libdir/tsearch2'
+	language 'C'
+	with (isstrict);
+
+CREATE FUNCTION set_curcfg(text)
+	returns void
+	as '$libdir/tsearch2', 'set_curcfg_byname'
+	language 'C'
+	with (isstrict);
+
+CREATE FUNCTION show_curcfg()
+	returns oid
+	as '$libdir/tsearch2'
+	language 'C'
+	with (isstrict);
+
+insert into pg_ts_cfg values ('default', 'default','C');
+insert into pg_ts_cfg values ('default_russian', 'default','ru_RU.KOI8-R');
+insert into pg_ts_cfg values ('simple', 'default');
+
+insert into pg_ts_cfgmap values ('default', 'lword', '{en_stem}');
+insert into pg_ts_cfgmap values ('default', 'nlword', '{simple}');
+insert into pg_ts_cfgmap values ('default', 'word', '{simple}');
+insert into pg_ts_cfgmap values ('default', 'email', '{simple}');
+insert into pg_ts_cfgmap values ('default', 'url', '{simple}');
+insert into pg_ts_cfgmap values ('default', 'host', '{simple}');
+insert into pg_ts_cfgmap values ('default', 'sfloat', '{simple}');
+insert into pg_ts_cfgmap values ('default', 'version', '{simple}');
+insert into pg_ts_cfgmap values ('default', 'part_hword', '{simple}');
+insert into pg_ts_cfgmap values ('default', 'nlpart_hword', '{simple}');
+insert into pg_ts_cfgmap values ('default', 'lpart_hword', '{en_stem}');
+insert into pg_ts_cfgmap values ('default', 'hword', '{simple}');
+insert into pg_ts_cfgmap values ('default', 'lhword', '{en_stem}');
+insert into pg_ts_cfgmap values ('default', 'nlhword', '{simple}');
+insert into pg_ts_cfgmap values ('default', 'uri', '{simple}');
+insert into pg_ts_cfgmap values ('default', 'file', '{simple}');
+insert into pg_ts_cfgmap values ('default', 'float', '{simple}');
+insert into pg_ts_cfgmap values ('default', 'int', '{simple}');
+insert into pg_ts_cfgmap values ('default', 'uint', '{simple}');
+insert into pg_ts_cfgmap values ('default_russian', 'lword', '{en_stem}');
+insert into pg_ts_cfgmap values ('default_russian', 'nlword', '{ru_stem}');
+insert into pg_ts_cfgmap values ('default_russian', 'word', '{ru_stem}');
+insert into pg_ts_cfgmap values ('default_russian', 'email', '{simple}');
+insert into pg_ts_cfgmap values ('default_russian', 'url', '{simple}');
+insert into pg_ts_cfgmap values ('default_russian', 'host', '{simple}');
+insert into pg_ts_cfgmap values ('default_russian', 'sfloat', '{simple}');
+insert into pg_ts_cfgmap values ('default_russian', 'version', '{simple}');
+insert into pg_ts_cfgmap values ('default_russian', 'part_hword', '{simple}');
+insert into pg_ts_cfgmap values ('default_russian', 'nlpart_hword', '{ru_stem}');
+insert into pg_ts_cfgmap values ('default_russian', 'lpart_hword', '{en_stem}');
+insert into pg_ts_cfgmap values ('default_russian', 'hword', '{ru_stem}');
+insert into pg_ts_cfgmap values ('default_russian', 'lhword', '{en_stem}');
+insert into pg_ts_cfgmap values ('default_russian', 'nlhword', '{ru_stem}');
+insert into pg_ts_cfgmap values ('default_russian', 'uri', '{simple}');
+insert into pg_ts_cfgmap values ('default_russian', 'file', '{simple}');
+insert into pg_ts_cfgmap values ('default_russian', 'float', '{simple}');
+insert into pg_ts_cfgmap values ('default_russian', 'int', '{simple}');
+insert into pg_ts_cfgmap values ('default_russian', 'uint', '{simple}');
+insert into pg_ts_cfgmap values ('simple', 'lword', '{simple}');
+insert into pg_ts_cfgmap values ('simple', 'nlword', '{simple}');
+insert into pg_ts_cfgmap values ('simple', 'word', '{simple}');
+insert into pg_ts_cfgmap values ('simple', 'email', '{simple}');
+insert into pg_ts_cfgmap values ('simple', 'url', '{simple}');
+insert into pg_ts_cfgmap values ('simple', 'host', '{simple}');
+insert into pg_ts_cfgmap values ('simple', 'sfloat', '{simple}');
+insert into pg_ts_cfgmap values ('simple', 'version', '{simple}');
+insert into pg_ts_cfgmap values ('simple', 'part_hword', '{simple}');
+insert into pg_ts_cfgmap values ('simple', 'nlpart_hword', '{simple}');
+insert into pg_ts_cfgmap values ('simple', 'lpart_hword', '{simple}');
+insert into pg_ts_cfgmap values ('simple', 'hword', '{simple}');
+insert into pg_ts_cfgmap values ('simple', 'lhword', '{simple}');
+insert into pg_ts_cfgmap values ('simple', 'nlhword', '{simple}');
+insert into pg_ts_cfgmap values ('simple', 'uri', '{simple}');
+insert into pg_ts_cfgmap values ('simple', 'file', '{simple}');
+insert into pg_ts_cfgmap values ('simple', 'float', '{simple}');
+insert into pg_ts_cfgmap values ('simple', 'int', '{simple}');
+insert into pg_ts_cfgmap values ('simple', 'uint', '{simple}');
+
+--tsvector type
+CREATE FUNCTION tsvector_in(cstring)
+RETURNS tsvector
+AS '$libdir/tsearch2'
+LANGUAGE 'C' with (isstrict);
+
+CREATE FUNCTION tsvector_out(tsvector)
+RETURNS cstring
+AS '$libdir/tsearch2'
+LANGUAGE 'C' with (isstrict);
+
+CREATE TYPE tsvector (
+        INTERNALLENGTH = -1,
+        INPUT = tsvector_in,
+        OUTPUT = tsvector_out,
+        STORAGE = extended
+);
+
+CREATE FUNCTION length(tsvector)
+RETURNS int4
+AS '$libdir/tsearch2', 'tsvector_length'
+LANGUAGE 'C' with (isstrict,iscachable);
+
+CREATE FUNCTION to_tsvector(oid, text)
+RETURNS tsvector
+AS '$libdir/tsearch2'
+LANGUAGE 'C' with (isstrict,iscachable);
+
+CREATE FUNCTION to_tsvector(text, text)
+RETURNS tsvector
+AS '$libdir/tsearch2', 'to_tsvector_name'
+LANGUAGE 'C' with (isstrict,iscachable);
+
+CREATE FUNCTION to_tsvector(text)
+RETURNS tsvector
+AS '$libdir/tsearch2', 'to_tsvector_current'
+LANGUAGE 'C' with (isstrict,iscachable);
+
+CREATE FUNCTION strip(tsvector)
+RETURNS tsvector
+AS '$libdir/tsearch2'
+LANGUAGE 'C' with (isstrict,iscachable);
+
+CREATE FUNCTION setweight(tsvector,"char")
+RETURNS tsvector
+AS '$libdir/tsearch2'
+LANGUAGE 'C' with (isstrict,iscachable);
+
+CREATE FUNCTION concat(tsvector,tsvector)
+RETURNS tsvector
+AS '$libdir/tsearch2'
+LANGUAGE 'C' with (isstrict,iscachable);
+
+CREATE OPERATOR || (
+        LEFTARG = tsvector,
+        RIGHTARG = tsvector,
+        PROCEDURE = concat
+);
+
+--query type
+CREATE FUNCTION tsquery_in(cstring)
+RETURNS tsquery
+AS '$libdir/tsearch2'
+LANGUAGE 'C' with (isstrict);
+
+CREATE FUNCTION tsquery_out(tsquery)
+RETURNS cstring
+AS '$libdir/tsearch2'
+LANGUAGE 'C' with (isstrict);
+
+CREATE TYPE tsquery (
+        INTERNALLENGTH = -1,
+        INPUT = tsquery_in,
+        OUTPUT = tsquery_out
+);
+
+CREATE FUNCTION querytree(tsquery)
+RETURNS text
+AS '$libdir/tsearch2', 'tsquerytree'
+LANGUAGE 'C' with (isstrict);
+
+CREATE FUNCTION to_tsquery(oid, text)
+RETURNS tsquery
+AS '$libdir/tsearch2'
+LANGUAGE 'c' with (isstrict,iscachable);
+
+CREATE FUNCTION to_tsquery(text, text)
+RETURNS tsquery
+AS '$libdir/tsearch2','to_tsquery_name'
+LANGUAGE 'c' with (isstrict,iscachable);
+
+CREATE FUNCTION to_tsquery(text)
+RETURNS tsquery
+AS '$libdir/tsearch2','to_tsquery_current'
+LANGUAGE 'c' with (isstrict,iscachable);
+
+--operations
+CREATE FUNCTION exectsq(tsvector, tsquery)
+RETURNS bool
+AS '$libdir/tsearch2'
+LANGUAGE 'C' with (isstrict, iscachable);
+  
+COMMENT ON FUNCTION exectsq(tsvector, tsquery) IS 'boolean operation with text index';
+
+CREATE FUNCTION rexectsq(tsquery, tsvector)
+RETURNS bool
+AS '$libdir/tsearch2'
+LANGUAGE 'C' with (isstrict, iscachable);
+
+COMMENT ON FUNCTION rexectsq(tsquery, tsvector) IS 'boolean operation with text index';
+
+CREATE OPERATOR @@ (
+        LEFTARG = tsvector,
+        RIGHTARG = tsquery,
+        PROCEDURE = exectsq,
+        COMMUTATOR = '@@',
+        RESTRICT = contsel,
+        JOIN = contjoinsel
+);
+CREATE OPERATOR @@ (
+        LEFTARG = tsquery,
+        RIGHTARG = tsvector,
+        PROCEDURE = rexectsq,
+        COMMUTATOR = '@@',
+        RESTRICT = contsel,
+        JOIN = contjoinsel
+);
+
+--Trigger
+CREATE FUNCTION tsearch2()
+RETURNS trigger
+AS '$libdir/tsearch2'
+LANGUAGE 'C';
+
+--Relevation
+CREATE FUNCTION rank(float4[], tsvector, tsquery)
+RETURNS float4
+AS '$libdir/tsearch2'
+LANGUAGE 'C' WITH (isstrict, iscachable);
+
+CREATE FUNCTION rank(float4[], tsvector, tsquery, int4)
+RETURNS float4
+AS '$libdir/tsearch2'
+LANGUAGE 'C' WITH (isstrict, iscachable);
+
+CREATE FUNCTION rank(tsvector, tsquery)
+RETURNS float4
+AS '$libdir/tsearch2', 'rank_def'
+LANGUAGE 'C' WITH (isstrict, iscachable);
+
+CREATE FUNCTION rank(tsvector, tsquery, int4)
+RETURNS float4
+AS '$libdir/tsearch2', 'rank_def'
+LANGUAGE 'C' WITH (isstrict, iscachable);
+
+CREATE FUNCTION rank_cd(int4, tsvector, tsquery)
+RETURNS float4
+AS '$libdir/tsearch2'
+LANGUAGE 'C' WITH (isstrict, iscachable);
+
+CREATE FUNCTION rank_cd(int4, tsvector, tsquery, int4)
+RETURNS float4
+AS '$libdir/tsearch2'
+LANGUAGE 'C' WITH (isstrict, iscachable);
+
+CREATE FUNCTION rank_cd(tsvector, tsquery)
+RETURNS float4
+AS '$libdir/tsearch2', 'rank_cd_def'
+LANGUAGE 'C' WITH (isstrict, iscachable);
+
+CREATE FUNCTION rank_cd(tsvector, tsquery, int4)
+RETURNS float4
+AS '$libdir/tsearch2', 'rank_cd_def'
+LANGUAGE 'C' WITH (isstrict, iscachable);
+
+CREATE FUNCTION headline(oid, text, tsquery, text)
+RETURNS text
+AS '$libdir/tsearch2', 'headline'
+LANGUAGE 'C' WITH (isstrict, iscachable);
+
+CREATE FUNCTION headline(oid, text, tsquery)
+RETURNS text
+AS '$libdir/tsearch2', 'headline'
+LANGUAGE 'C' WITH (isstrict, iscachable);
+
+CREATE FUNCTION headline(text, text, tsquery, text)
+RETURNS text
+AS '$libdir/tsearch2', 'headline_byname'
+LANGUAGE 'C' WITH (isstrict, iscachable);
+
+CREATE FUNCTION headline(text, text, tsquery)
+RETURNS text
+AS '$libdir/tsearch2', 'headline_byname'
+LANGUAGE 'C' WITH (isstrict, iscachable);
+
+CREATE FUNCTION headline(text, tsquery, text)
+RETURNS text
+AS '$libdir/tsearch2', 'headline_current'
+LANGUAGE 'C' WITH (isstrict, iscachable);
+
+CREATE FUNCTION headline(text, tsquery)
+RETURNS text
+AS '$libdir/tsearch2', 'headline_current'
+LANGUAGE 'C' WITH (isstrict, iscachable);
+
+--GiST
+--GiST key type 
+CREATE FUNCTION gtsvector_in(cstring)
+RETURNS gtsvector
+AS '$libdir/tsearch2'
+LANGUAGE 'C' with (isstrict);
+
+CREATE FUNCTION gtsvector_out(gtsvector)
+RETURNS cstring
+AS '$libdir/tsearch2'
+LANGUAGE 'C' with (isstrict);
+
+CREATE TYPE gtsvector (
+        INTERNALLENGTH = -1,
+        INPUT = gtsvector_in,
+        OUTPUT = gtsvector_out
+);
+
+-- support FUNCTIONs
+CREATE FUNCTION gtsvector_consistent(gtsvector,internal,int4)
+RETURNS bool
+AS '$libdir/tsearch2'
+LANGUAGE 'C';
+  
+CREATE FUNCTION gtsvector_compress(internal)
+RETURNS internal
+AS '$libdir/tsearch2'
+LANGUAGE 'C';
+
+CREATE FUNCTION gtsvector_decompress(internal)
+RETURNS internal
+AS '$libdir/tsearch2'
+LANGUAGE 'C';
+
+CREATE FUNCTION gtsvector_penalty(internal,internal,internal)
+RETURNS internal
+AS '$libdir/tsearch2'
+LANGUAGE 'C' with (isstrict);
+
+CREATE FUNCTION gtsvector_picksplit(internal, internal)
+RETURNS internal
+AS '$libdir/tsearch2'
+LANGUAGE 'C';
+
+CREATE FUNCTION gtsvector_union(bytea, internal)
+RETURNS _int4
+AS '$libdir/tsearch2'
+LANGUAGE 'C';
+
+CREATE FUNCTION gtsvector_same(gtsvector, gtsvector, internal)
+RETURNS internal
+AS '$libdir/tsearch2'
+LANGUAGE 'C';
+
+-- CREATE the OPERATOR class
+CREATE OPERATOR CLASS gist_tsvector_ops
+DEFAULT FOR TYPE tsvector USING gist
+AS
+        OPERATOR        1       @@ (tsvector, tsquery)  RECHECK ,
+        FUNCTION        1       gtsvector_consistent (gtsvector, internal, int4),
+        FUNCTION        2       gtsvector_union (bytea, internal),
+        FUNCTION        3       gtsvector_compress (internal),
+        FUNCTION        4       gtsvector_decompress (internal),
+        FUNCTION        5       gtsvector_penalty (internal, internal, internal),
+        FUNCTION        6       gtsvector_picksplit (internal, internal),
+        FUNCTION        7       gtsvector_same (gtsvector, gtsvector, internal),
+        STORAGE         gtsvector;
+
+
+--stat info
+CREATE TYPE statinfo 
+	as (word text, ndoc int4, nentry int4);
+
+--CREATE FUNCTION tsstat_in(cstring)
+--RETURNS tsstat
+--AS '$libdir/tsearch2'
+--LANGUAGE 'C' with (isstrict);
+--
+--CREATE FUNCTION tsstat_out(tsstat)
+--RETURNS cstring
+--AS '$libdir/tsearch2'
+--LANGUAGE 'C' with (isstrict);
+--
+--CREATE TYPE tsstat (
+--        INTERNALLENGTH = -1,
+--        INPUT = tsstat_in,
+--        OUTPUT = tsstat_out,
+--        STORAGE = plain
+--);
+--
+--CREATE FUNCTION ts_accum(tsstat,tsvector)
+--RETURNS tsstat
+--AS '$libdir/tsearch2'
+--LANGUAGE 'C' with (isstrict);
+--
+--CREATE FUNCTION ts_accum_finish(tsstat)
+--	returns setof statinfo
+--	as '$libdir/tsearch2'
+--	language 'C'
+--	with (isstrict);
+--
+--CREATE AGGREGATE stat (
+--	BASETYPE=tsvector,
+--	SFUNC=ts_accum,
+--	STYPE=tsstat,
+--	FINALFUNC = ts_accum_finish,
+--	initcond = ''
+--); 
+
+CREATE FUNCTION stat(text)
+	returns setof statinfo
+	as '$libdir/tsearch2', 'ts_stat'
+	language 'C'
+	with (isstrict);
+
+--reset - just for debuging
+CREATE FUNCTION reset_tsearch()
+        returns void
+        as '$libdir/tsearch2'
+        language 'C'
+        with (isstrict);
+
+--get cover (debug for rank_cd)
+CREATE FUNCTION get_covers(tsvector,tsquery)
+        returns text
+        as '$libdir/tsearch2'
+        language 'C'
+        with (isstrict);
+
+--debug function
+create type tsdebug as (
+        ts_name text,
+        tok_type text,
+        description text,
+        token   text,
+        dict_name text[],
+        "tsvector" tsvector
+);
+
+create function _get_parser_from_curcfg() 
+returns text as 
+' select prs_name from pg_ts_cfg where oid = show_curcfg() '
+language 'SQL' with(isstrict,iscachable);
+
+create function ts_debug(text)
+returns setof tsdebug as '
+select 
+        m.ts_name,
+        t.alias as tok_type,
+        t.descr as description,
+        p.token,
+        m.dict_name,
+        strip(to_tsvector(p.token)) as tsvector
+from
+        parse( _get_parser_from_curcfg(), $1 ) as p,
+        token_type() as t,
+        pg_ts_cfgmap as m,
+        pg_ts_cfg as c
+where
+        t.tokid=p.tokid and
+        t.alias = m.tok_alias and 
+        m.ts_name=c.ts_name and 
+        c.oid=show_curcfg() 
+' language 'SQL' with(isstrict);
+"""
+
+def setup(cursor):
+    sql = '\n'.join([line for line in tsearch_sql.split('\n')
+                     if not line.startswith('--')])
+    for query in sql.split(';'):
+        if query.strip():
+            cursor.execute(query)

Added: tracker/vendor/roundup/current/roundup/cgi/.cvsignore
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/.cvsignore	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,3 @@
+*.pyc
+*.pyo
+*.cover

Added: tracker/vendor/roundup/current/roundup/cgi/MultiMapping.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/MultiMapping.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,63 @@
+class MultiMapping:
+    def __init__(self, *stores):
+        self.stores = list(stores)
+        self.stores.reverse()
+
+    def __getitem__(self, key):
+        for store in self.stores:
+            if store.has_key(key):
+                return store[key]
+        raise KeyError, key
+
+    def __setitem__(self, key, val):
+        self.stores[0][key] = val
+
+    _marker = []
+
+    def get(self, key, default=_marker):
+        for store in self.stores:
+            if store.has_key(key):
+                return store[key]
+        if default is self._marker:
+            raise KeyError, key
+        return default
+
+    def __len__(self):
+        return len(self.items())
+
+    def has_key(self, key):
+        for store in self.stores:
+            if store.has_key(key):
+                return 1
+        return 0
+
+    def push(self, store):
+        self.stores = [store] + self.stores
+
+    def pop(self):
+        if not len(self.stores):
+            return None
+        store, self.stores = self.stores[0], self.stores[1:]
+        return store
+
+    def keys(self):
+        return [ _[0] for _ in self.items() ]
+
+    def values(self):
+        return [ _[1] for _ in self.items() ]
+
+    def copy(self):
+       copy = MultiMapping()
+       copy.stores = [_.copy() for _ in self.stores]
+       return copy
+
+    def items(self):
+        l = []
+        seen = {}
+        for store in self.stores:
+            for k, v in store.items():
+                if not seen.has_key(k):
+                    l.append((k, v))
+                    seen[k] = 1
+        return l
+

Added: tracker/vendor/roundup/current/roundup/cgi/PageTemplates/.cvsignore
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/PageTemplates/.cvsignore	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,2 @@
+*.pyc
+*.pyo

Added: tracker/vendor/roundup/current/roundup/cgi/PageTemplates/Expressions.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/PageTemplates/Expressions.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,343 @@
+##############################################################################
+#
+# Copyright (c) 2001 Zope Corporation and Contributors. All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.0 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE
+#
+##############################################################################
+# Modified for Roundup:
+# 
+# 1. removed all Zope-specific code (doesn't even try to import that stuff now)
+# 2. removed all Acquisition
+# 3. removed blocking of leading-underscore URL components
+
+"""Page Template Expression Engine
+
+Page Template-specific implementation of TALES, with handlers
+for Python expressions, string literals, and paths.
+"""
+
+__version__='$Revision: 1.12 $'[11:-2]
+
+import re, sys
+from TALES import Engine, CompilerError, _valid_name, NAME_RE, \
+     Undefined, Default, _parse_expr
+
+
+_engine = None
+def getEngine():
+    global _engine
+    if _engine is None:
+        from PathIterator import Iterator
+        _engine = Engine(Iterator)
+        installHandlers(_engine)
+    return _engine
+
+def installHandlers(engine):
+    reg = engine.registerType
+    pe = PathExpr
+    for pt in ('standard', 'path', 'exists', 'nocall'):
+        reg(pt, pe)
+    reg('string', StringExpr)
+    reg('python', PythonExpr)
+    reg('not', NotExpr)
+    reg('defer', DeferExpr)
+
+from PythonExpr import getSecurityManager, PythonExpr
+guarded_getattr = getattr
+try:
+    from zExceptions import Unauthorized
+except ImportError:
+    Unauthorized = "Unauthorized"
+
+def acquisition_security_filter(orig, inst, name, v, real_validate):
+    if real_validate(orig, inst, name, v):
+        return 1
+    raise Unauthorized, name
+
+def call_with_ns(f, ns, arg=1):
+    if arg==2:
+        return f(None, ns)
+    else:
+        return f(ns)
+
+class _SecureModuleImporter:
+    """Simple version of the importer for use with trusted code."""
+    __allow_access_to_unprotected_subobjects__ = 1
+    def __getitem__(self, module):
+        __import__(module)
+        return sys.modules[module]
+
+SecureModuleImporter = _SecureModuleImporter()
+
+Undefs = (Undefined, AttributeError, KeyError,
+          TypeError, IndexError, Unauthorized)
+
+def render(ob, ns):
+    """
+    Calls the object, possibly a document template, or just returns it if
+    not callable.  (From DT_Util.py)
+    """
+    if hasattr(ob, '__render_with_namespace__'):
+        ob = call_with_ns(ob.__render_with_namespace__, ns)
+    else:
+        base = ob
+        if callable(base):
+            try:
+                if getattr(base, 'isDocTemp', 0):
+                    ob = call_with_ns(ob, ns, 2)
+                else:
+                    ob = ob()
+            except AttributeError, n:
+                if str(n) != '__call__':
+                    raise
+    return ob
+
+class SubPathExpr:
+    def __init__(self, path):
+        self._path = path = path.strip().split('/')
+        self._base = base = path.pop(0)
+        if base and not _valid_name(base):
+            raise CompilerError, 'Invalid variable name "%s"' % base
+        # Parse path
+        self._dp = dp = []
+        for i in range(len(path)):
+            e = path[i]
+            if e[:1] == '?' and _valid_name(e[1:]):
+                dp.append((i, e[1:]))
+        dp.reverse()
+
+    def _eval(self, econtext,
+              list=list, isinstance=isinstance, StringType=type('')):
+        vars = econtext.vars
+        path = self._path
+        if self._dp:
+            path = list(path) # Copy!
+            for i, varname in self._dp:
+                val = vars[varname]
+                if isinstance(val, StringType):
+                    path[i] = val
+                else:
+                    # If the value isn't a string, assume it's a sequence
+                    # of path names.
+                    path[i:i+1] = list(val)
+        base = self._base
+        __traceback_info__ = 'path expression "%s"'%('/'.join(self._path))
+        if base == 'CONTEXTS' or not base:
+            ob = econtext.contexts
+        else:
+            ob = vars[base]
+        if isinstance(ob, DeferWrapper):
+            ob = ob()
+        if path:
+            ob = restrictedTraverse(ob, path, getSecurityManager())
+        return ob
+
+class PathExpr:
+    def __init__(self, name, expr, engine):
+        self._s = expr
+        self._name = name
+        self._hybrid = 0
+        paths = expr.split('|')
+        self._subexprs = []
+        add = self._subexprs.append
+        for i in range(len(paths)):
+            path = paths[i].lstrip()
+            if _parse_expr(path):
+                # This part is the start of another expression type,
+                # so glue it back together and compile it.
+                add(engine.compile(('|'.join(paths[i:]).lstrip())))
+                self._hybrid = 1
+                break
+            add(SubPathExpr(path)._eval)
+
+    def _exists(self, econtext):
+        for expr in self._subexprs:
+            try:
+                expr(econtext)
+            except Undefs:
+                pass
+            else:
+                return 1
+        return 0
+
+    def _eval(self, econtext,
+              isinstance=isinstance, StringType=type(''), render=render):
+        for expr in self._subexprs[:-1]:
+            # Try all but the last subexpression, skipping undefined ones.
+            try:
+                ob = expr(econtext)
+            except Undefs:
+                pass
+            else:
+                break
+        else:
+            # On the last subexpression allow exceptions through, and
+            # don't autocall if the expression was not a subpath.
+            ob = self._subexprs[-1](econtext)
+            if self._hybrid:
+                return ob
+
+        if self._name == 'nocall' or isinstance(ob, StringType):
+            return ob
+        # Return the rendered object
+        return render(ob, econtext.vars)
+
+    def __call__(self, econtext):
+        if self._name == 'exists':
+            return self._exists(econtext)
+        return self._eval(econtext)
+
+    def __str__(self):
+        return '%s expression %s' % (self._name, `self._s`)
+
+    def __repr__(self):
+        return '%s:%s' % (self._name, `self._s`)
+
+
+_interp = re.compile(r'\$(%(n)s)|\${(%(n)s(?:/[^}]*)*)}' % {'n': NAME_RE})
+
+class StringExpr:
+    def __init__(self, name, expr, engine):
+        self._s = expr
+        if '%' in expr:
+            expr = expr.replace('%', '%%')
+        self._vars = vars = []
+        if '$' in expr:
+            parts = []
+            for exp in expr.split('$$'):
+                if parts: parts.append('$')
+                m = _interp.search(exp)
+                while m is not None:
+                    parts.append(exp[:m.start()])
+                    parts.append('%s')
+                    vars.append(PathExpr('path', m.group(1) or m.group(2),
+                                         engine))
+                    exp = exp[m.end():]
+                    m = _interp.search(exp)
+                if '$' in exp:
+                    raise CompilerError, (
+                        '$ must be doubled or followed by a simple path')
+                parts.append(exp)
+            expr = ''.join(parts)
+        self._expr = expr
+
+    def __call__(self, econtext):
+        vvals = []
+        for var in self._vars:
+            v = var(econtext)
+            # I hope this isn't in use anymore.
+            ## if isinstance(v, Exception):
+            ##     raise v
+            vvals.append(v)
+        return self._expr % tuple(vvals)
+
+    def __str__(self):
+        return 'string expression %s' % `self._s`
+
+    def __repr__(self):
+        return 'string:%s' % `self._s`
+
+class NotExpr:
+    def __init__(self, name, expr, compiler):
+        self._s = expr = expr.lstrip()
+        self._c = compiler.compile(expr)
+
+    def __call__(self, econtext):
+        # We use the (not x) and 1 or 0 formulation to avoid changing
+        # the representation of the result in Python 2.3, where the
+        # result of "not" becomes an instance of bool.
+        return (not econtext.evaluateBoolean(self._c)) and 1 or 0
+
+    def __repr__(self):
+        return 'not:%s' % `self._s`
+
+class DeferWrapper:
+    def __init__(self, expr, econtext):
+        self._expr = expr
+        self._econtext = econtext
+
+    def __str__(self):
+        return str(self())
+
+    def __call__(self):
+        return self._expr(self._econtext)
+
+class DeferExpr:
+    def __init__(self, name, expr, compiler):
+        self._s = expr = expr.lstrip()
+        self._c = compiler.compile(expr)
+
+    def __call__(self, econtext):
+        return DeferWrapper(self._c, econtext)
+
+    def __repr__(self):
+        return 'defer:%s' % `self._s`
+
+class TraversalError:
+    def __init__(self, path, name):
+        self.path = path
+        self.name = name
+
+
+
+def restrictedTraverse(object, path, securityManager,
+                       get=getattr, has=hasattr, N=None, M=[],
+                       TupleType=type(()) ):
+
+    REQUEST = {'path': path}
+    REQUEST['TraversalRequestNameStack'] = path = path[:] # Copy!
+    path.reverse()
+    validate = securityManager.validate
+    __traceback_info__ = REQUEST
+    done = []
+    while path:
+        name = path.pop()
+        __traceback_info__ = TraversalError(done, name)
+
+        if isinstance(name, TupleType):
+            object = object(*name)
+            continue
+
+        if not name:
+            # Skip directly to item access
+            o = object[name]
+            # Check access to the item.
+            if not validate(object, object, name, o):
+                raise Unauthorized, name
+            object = o
+            continue
+
+        # Try an attribute.
+        o = guarded_getattr(object, name, M)
+        if o is M:
+            # Try an item.
+            try:
+                # XXX maybe in Python 2.2 we can just check whether
+                # the object has the attribute "__getitem__"
+                # instead of blindly catching exceptions.
+                o = object[name]
+            except AttributeError, exc:
+                if str(exc).find('__getitem__') >= 0:
+                    # The object does not support the item interface.
+                    # Try to re-raise the original attribute error.
+                    # XXX I think this only happens with
+                    # ExtensionClass instances.
+                    guarded_getattr(object, name)
+                raise
+            except TypeError, exc:
+                if str(exc).find('unsubscriptable') >= 0:
+                    # The object does not support the item interface.
+                    # Try to re-raise the original attribute error.
+                    # XXX This is sooooo ugly.
+                    guarded_getattr(object, name)
+                raise
+        done.append((name, o))
+        object = o
+
+    return object

Added: tracker/vendor/roundup/current/roundup/cgi/PageTemplates/GlobalTranslationService.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/PageTemplates/GlobalTranslationService.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,49 @@
+##############################################################################
+#
+# Copyright (c) 2002 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.0 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+# Modifications for Roundup:
+# 1. implemented ustr as str
+# 2. make imports use roundup.cgi
+"""Global Translation Service for providing I18n to Page Templates.
+
+$Id: GlobalTranslationService.py,v 1.4 2004/05/29 00:08:07 a1s Exp $
+"""
+
+import re
+
+from roundup.cgi.TAL.TALDefs import NAME_RE
+
+ustr = str
+
+class DummyTranslationService:
+    """Translation service that doesn't know anything about translation."""
+    def translate(self, domain, msgid, mapping=None,
+                  context=None, target_language=None, default=None):
+        def repl(m, mapping=mapping):
+            return ustr(mapping[m.group(m.lastindex)])
+        cre = re.compile(r'\$(?:(%s)|\{(%s)\})' % (NAME_RE, NAME_RE))
+        return cre.sub(repl, default or msgid)
+    # XXX Not all of Zope.I18n.ITranslationService is implemented.
+
+translationService = DummyTranslationService()
+
+def setGlobalTranslationService(service):
+    """Sets the global translation service, and returns the previous one."""
+    global translationService
+    old_service = translationService
+    translationService = service
+    return old_service
+
+def getGlobalTranslationService():
+    """Returns the global translation service."""
+    return translationService

Added: tracker/vendor/roundup/current/roundup/cgi/PageTemplates/MultiMapping.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/PageTemplates/MultiMapping.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,29 @@
+import operator
+
+class MultiMapping:
+    def __init__(self, *stores):
+        self.stores = list(stores)
+    def __getitem__(self, key):
+        for store in self.stores:
+            if store.has_key(key):
+                return store[key]
+        raise KeyError, key
+    _marker = []
+    def get(self, key, default=_marker):
+        for store in self.stores:
+            if store.has_key(key):
+                return store[key]
+        if default is self._marker:
+            raise KeyError, key
+        return default
+    def __len__(self):
+        return reduce(operator.add, [len(x) for x in self.stores], 0)
+    def push(self, store):
+        self.stores.append(store)
+    def pop(self):
+        return self.stores.pop()
+    def items(self):
+        l = []
+        for store in self.stores:
+            l = l + store.items()
+        return l

Added: tracker/vendor/roundup/current/roundup/cgi/PageTemplates/PageTemplate.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/PageTemplates/PageTemplate.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,218 @@
+##############################################################################
+#
+# Copyright (c) 2001 Zope Corporation and Contributors. All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.0 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE
+#
+##############################################################################
+# Modified for Roundup:
+# 
+# 1. changed imports to import from roundup.cgi
+# 2. removed use of ExtensionClass
+# 3. removed use of ComputedAttribute
+"""Page Template module
+
+HTML- and XML-based template objects using TAL, TALES, and METAL.
+"""
+
+__version__='$Revision: 1.5 $'[11:-2]
+
+import sys
+
+from roundup.cgi.TAL.TALParser import TALParser
+from roundup.cgi.TAL.HTMLTALParser import HTMLTALParser
+from roundup.cgi.TAL.TALGenerator import TALGenerator
+# Do not use cStringIO here!  It's not unicode aware. :(
+from roundup.cgi.TAL.TALInterpreter import TALInterpreter, FasterStringIO
+from Expressions import getEngine
+
+
+class PageTemplate:
+    "Page Templates using TAL, TALES, and METAL"
+
+    content_type = 'text/html'
+    expand = 0
+    _v_errors = ()
+    _v_warnings = ()
+    _v_program = None
+    _v_macros = None
+    _v_cooked = 0
+    id = '(unknown)'
+    _text = ''
+    _error_start = '<!-- Page Template Diagnostics'
+
+    def StringIO(self):
+        # Third-party products wishing to provide a full Unicode-aware
+        # StringIO can do so by monkey-patching this method.
+        return FasterStringIO()
+
+    def pt_edit(self, text, content_type):
+        if content_type:
+            self.content_type = str(content_type)
+        if hasattr(text, 'read'):
+            text = text.read()
+        self.write(text)
+
+    def pt_getContext(self):
+        c = {'template': self,
+             'options': {},
+             'nothing': None,
+             'request': None,
+             'modules': ModuleImporter,
+             }
+        parent = getattr(self, 'aq_parent', None)
+        if parent is not None:
+            c['here'] = parent
+            c['container'] = self.aq_inner.aq_parent
+            while parent is not None:
+                self = parent
+                parent = getattr(self, 'aq_parent', None)
+            c['root'] = self
+        return c
+
+    def pt_render(self, source=0, extra_context={}):
+        """Render this Page Template"""
+        if not self._v_cooked:
+            self._cook()
+
+        __traceback_supplement__ = (PageTemplateTracebackSupplement, self)
+
+        if self._v_errors:
+            raise PTRuntimeError, 'Page Template %s has errors.' % self.id
+        output = self.StringIO()
+        c = self.pt_getContext()
+        c.update(extra_context)
+
+        TALInterpreter(self._v_program, self._v_macros,
+                       getEngine().getContext(c),
+                       output,
+                       tal=not source, strictinsert=0)()
+        return output.getvalue()
+
+    def __call__(self, *args, **kwargs):
+        if not kwargs.has_key('args'):
+            kwargs['args'] = args
+        return self.pt_render(extra_context={'options': kwargs})
+
+    def pt_errors(self):
+        if not self._v_cooked:
+            self._cook()
+        err = self._v_errors
+        if err:
+            return err
+        if not self.expand: return
+        try:
+            self.pt_render(source=1)
+        except:
+            return ('Macro expansion failed', '%s: %s' % sys.exc_info()[:2])
+
+    def pt_warnings(self):
+        if not self._v_cooked:
+            self._cook()
+        return self._v_warnings
+
+    def pt_macros(self):
+        if not self._v_cooked:
+            self._cook()
+        __traceback_supplement__ = (PageTemplateTracebackSupplement, self)
+        if self._v_errors:
+            raise PTRuntimeError, 'Page Template %s has errors.' % self.id
+        return self._v_macros
+
+    def __getattr__(self, name):
+        if name == 'macros':
+            return self.pt_macros()
+        raise AttributeError, name
+
+    def pt_source_file(self):
+        return None  # Unknown.
+
+    def write(self, text):
+        assert type(text) is type('')
+        if text[:len(self._error_start)] == self._error_start:
+            errend = text.find('-->')
+            if errend >= 0:
+                text = text[errend + 4:]
+        if self._text != text:
+            self._text = text
+        self._cook()
+
+    def read(self):
+        self._cook_check()
+        if not self._v_errors:
+            if not self.expand:
+                return self._text
+            try:
+                return self.pt_render(source=1)
+            except:
+                return ('%s\n Macro expansion failed\n %s\n-->\n%s' %
+                        (self._error_start, "%s: %s" % sys.exc_info()[:2],
+                         self._text) )
+
+        return ('%s\n %s\n-->\n%s' % (self._error_start,
+                                      '\n '.join(self._v_errors),
+                                      self._text))
+
+    def _cook_check(self):
+        if not self._v_cooked:
+            self._cook()
+
+    def _cook(self):
+        """Compile the TAL and METAL statments.
+
+        Cooking must not fail due to compilation errors in templates.
+        """
+        source_file = self.pt_source_file()
+        if self.html():
+            gen = TALGenerator(getEngine(), xml=0, source_file=source_file)
+            parser = HTMLTALParser(gen)
+        else:
+            gen = TALGenerator(getEngine(), source_file=source_file)
+            parser = TALParser(gen)
+
+        self._v_errors = ()
+        try:
+            parser.parseString(self._text)
+            self._v_program, self._v_macros = parser.getCode()
+        except:
+            self._v_errors = ["Compilation failed",
+                              "%s: %s" % sys.exc_info()[:2]]
+        self._v_warnings = parser.getWarnings()
+        self._v_cooked = 1
+
+    def html(self):
+        if not hasattr(getattr(self, 'aq_base', self), 'is_html'):
+            return self.content_type == 'text/html'
+        return self.is_html
+
+class _ModuleImporter:
+    def __getitem__(self, module):
+        mod = __import__(module)
+        path = module.split('.')
+        for name in path[1:]:
+            mod = getattr(mod, name)
+        return mod
+
+ModuleImporter = _ModuleImporter()
+
+class PTRuntimeError(RuntimeError):
+    '''The Page Template has template errors that prevent it from rendering.'''
+    pass
+
+
+class PageTemplateTracebackSupplement:
+    #__implements__ = ITracebackSupplement
+
+    def __init__(self, pt):
+        self.object = pt
+        w = pt.pt_warnings()
+        e = pt.pt_errors()
+        if e:
+            w = list(w) + list(e)
+        self.warnings = w
+

Added: tracker/vendor/roundup/current/roundup/cgi/PageTemplates/PathIterator.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/PageTemplates/PathIterator.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,46 @@
+##############################################################################
+#
+# Copyright (c) 2001 Zope Corporation and Contributors. All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.0 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE
+#
+##############################################################################
+
+"""Path Iterator
+
+A TALES Iterator with the ability to use first() and last() on
+subpaths of elements.
+"""
+
+__version__='$Revision: 1.3 $'[11:-2]
+
+import TALES
+from Expressions import restrictedTraverse, Undefs, getSecurityManager
+
+class Iterator(TALES.Iterator):
+    def __bobo_traverse__(self, REQUEST, name):
+        if name in ('first', 'last'):
+            path = REQUEST['TraversalRequestNameStack']
+            names = list(path)
+            names.reverse()
+            path[:] = [tuple(names)]
+        return getattr(self, name)
+
+    def same_part(self, name, ob1, ob2):
+        if name is None:
+            return ob1 == ob2
+        if isinstance(name, type('')):
+            name = name.split('/')
+        name = filter(None, name)
+        securityManager = getSecurityManager()
+        try:
+            ob1 = restrictedTraverse(ob1, name, securityManager)
+            ob2 = restrictedTraverse(ob2, name, securityManager)
+        except Undefs:
+            return 0
+        return ob1 == ob2

Added: tracker/vendor/roundup/current/roundup/cgi/PageTemplates/PythonExpr.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/PageTemplates/PythonExpr.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,85 @@
+##############################################################################
+#
+# Copyright (c) 2001 Zope Corporation and Contributors. All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.0 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE
+#
+##############################################################################
+# Modified for Roundup:
+# 
+# 1. more informative traceback info
+
+"""Generic Python Expression Handler
+"""
+
+__version__='$Revision: 1.6 $'[11:-2]
+
+from TALES import CompilerError
+from sys import exc_info
+
+class getSecurityManager:
+    '''Null security manager'''
+    def validate(self, *args, **kwargs):
+        return 1
+    addContext = removeContext = validateValue = validate
+
+class PythonExpr:
+    def __init__(self, name, expr, engine):
+        self.expr = expr = expr.strip().replace('\n', ' ')
+        try:
+            d = {}
+            exec 'def f():\n return %s\n' % expr.strip() in d
+            self._f = d['f']
+        except:
+            raise CompilerError, ('Python expression error:\n'
+                                  '%s: %s') % exc_info()[:2]
+        self._get_used_names()
+
+    def _get_used_names(self):
+        self._f_varnames = vnames = []
+        for vname in self._f.func_code.co_names:
+            if vname[0] not in '$_':
+                vnames.append(vname)
+
+    def _bind_used_names(self, econtext, _marker=[]):
+        # Bind template variables
+        names = {'CONTEXTS': econtext.contexts}
+        vars = econtext.vars
+        getType = econtext.getCompiler().getTypes().get
+        for vname in self._f_varnames:
+            val = vars.get(vname, _marker)
+            if val is _marker:
+                has = val = getType(vname)
+                if has:
+                    val = ExprTypeProxy(vname, val, econtext)
+                    names[vname] = val
+            else:
+                names[vname] = val
+        return names
+
+    def __call__(self, econtext):
+        __traceback_info__ = 'python expression "%s"'%self.expr
+        f = self._f
+        f.func_globals.update(self._bind_used_names(econtext))
+        return f()
+
+    def __str__(self):
+        return 'Python expression "%s"' % self.expr
+    def __repr__(self):
+        return '<PythonExpr %s>' % self.expr
+
+class ExprTypeProxy:
+    '''Class that proxies access to an expression type handler'''
+    def __init__(self, name, handler, econtext):
+        self._name = name
+        self._handler = handler
+        self._econtext = econtext
+    def __call__(self, text):
+        return self._handler(self._name, text,
+                             self._econtext.getCompiler())(self._econtext)
+

Added: tracker/vendor/roundup/current/roundup/cgi/PageTemplates/README.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/PageTemplates/README.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,7 @@
+See <a href="http://dev.zope.org/Wikis/DevSite/Projects/ZPT">the
+ZPT project Wiki</a> for more information about Page Templates, or
+<a href="http://www.zope.org/Members/4am/ZPT">the download page</a>
+for installation instructions and the most recent version of the software.
+
+This Product requires the TAL and ZTUtils packages to be installed in
+your Python path (not Products).  See the links above for more information.

Added: tracker/vendor/roundup/current/roundup/cgi/PageTemplates/TALES.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/PageTemplates/TALES.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,300 @@
+##############################################################################
+#
+# Copyright (c) 2001 Zope Corporation and Contributors. All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.0 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE
+#
+##############################################################################
+# Modified for Roundup:
+# 
+# 1. changed imports to import from roundup.cgi
+# 2. implemented ustr as str (removes import from DocumentTemplate)
+# 3. removed import and use of Unauthorized from zExceptions
+"""TALES
+
+An implementation of a generic TALES engine
+"""
+
+__version__='$Revision: 1.9 $'[11:-2]
+
+import re, sys
+from roundup.cgi import ZTUtils
+from weakref import ref
+from MultiMapping import MultiMapping
+from GlobalTranslationService import getGlobalTranslationService
+
+ustr = str
+
+StringType = type('')
+
+NAME_RE = r"[a-zA-Z][a-zA-Z0-9_]*"
+_parse_expr = re.compile(r"(%s):" % NAME_RE).match
+_valid_name = re.compile('%s$' % NAME_RE).match
+
+class TALESError(Exception):
+    """Error during TALES expression evaluation"""
+
+class Undefined(TALESError):
+    '''Exception raised on traversal of an undefined path'''
+
+class RegistrationError(Exception):
+    '''TALES Type Registration Error'''
+
+class CompilerError(Exception):
+    '''TALES Compiler Error'''
+
+class Default:
+    '''Retain Default'''
+Default = Default()
+
+class SafeMapping(MultiMapping):
+    '''Mapping with security declarations and limited method exposure.
+
+    Since it subclasses MultiMapping, this class can be used to wrap
+    one or more mapping objects.  Restricted Python code will not be
+    able to mutate the SafeMapping or the wrapped mappings, but will be
+    able to read any value.
+    '''
+    __allow_access_to_unprotected_subobjects__ = 1
+    push = pop = None
+
+    _push = MultiMapping.push
+    _pop = MultiMapping.pop
+
+
+class Iterator(ZTUtils.Iterator):
+    def __init__(self, name, seq, context):
+        ZTUtils.Iterator.__init__(self, seq)
+        self.name = name
+        self._context_ref = ref(context)
+
+    def next(self):
+        if ZTUtils.Iterator.next(self):
+            context = self._context_ref()
+            if context is not None:
+                context.setLocal(self.name, self.item)
+            return 1
+        return 0
+
+
+class ErrorInfo:
+    """Information about an exception passed to an on-error handler."""
+    __allow_access_to_unprotected_subobjects__ = 1
+
+    def __init__(self, err, position=(None, None)):
+        if isinstance(err, Exception):
+            self.type = err.__class__
+            self.value = err
+        else:
+            self.type = err
+            self.value = None
+        self.lineno = position[0]
+        self.offset = position[1]
+
+
+class Engine:
+    '''Expression Engine
+
+    An instance of this class keeps a mutable collection of expression
+    type handlers.  It can compile expression strings by delegating to
+    these handlers.  It can provide an expression Context, which is
+    capable of holding state and evaluating compiled expressions.
+    '''
+    Iterator = Iterator
+
+    def __init__(self, Iterator=None):
+        self.types = {}
+        if Iterator is not None:
+            self.Iterator = Iterator
+
+    def registerType(self, name, handler):
+        if not _valid_name(name):
+            raise RegistrationError, 'Invalid Expression type "%s".' % name
+        types = self.types
+        if types.has_key(name):
+            raise RegistrationError, (
+                'Multiple registrations for Expression type "%s".' %
+                name)
+        types[name] = handler
+
+    def getTypes(self):
+        return self.types
+
+    def compile(self, expression):
+        m = _parse_expr(expression)
+        if m:
+            type = m.group(1)
+            expr = expression[m.end():]
+        else:
+            type = "standard"
+            expr = expression
+        try:
+            handler = self.types[type]
+        except KeyError:
+            raise CompilerError, (
+                'Unrecognized expression type "%s".' % type)
+        return handler(type, expr, self)
+
+    def getContext(self, contexts=None, **kwcontexts):
+        if contexts is not None:
+            if kwcontexts:
+                kwcontexts.update(contexts)
+            else:
+                kwcontexts = contexts
+        return Context(self, kwcontexts)
+
+    def getCompilerError(self):
+        return CompilerError
+
+class Context:
+    '''Expression Context
+
+    An instance of this class holds context information that it can
+    use to evaluate compiled expressions.
+    '''
+
+    _context_class = SafeMapping
+    position = (None, None)
+    source_file = None
+
+    def __init__(self, compiler, contexts):
+        self._compiler = compiler
+        self.contexts = contexts
+        contexts['nothing'] = None
+        contexts['default'] = Default
+
+        self.repeat_vars = rv = {}
+        # Wrap this, as it is visible to restricted code
+        contexts['repeat'] = rep =  self._context_class(rv)
+        contexts['loop'] = rep # alias
+
+        self.global_vars = gv = contexts.copy()
+        self.local_vars = lv = {}
+        self.vars = self._context_class(gv, lv)
+
+        # Keep track of what needs to be popped as each scope ends.
+        self._scope_stack = []
+
+    def getCompiler(self):
+        return self._compiler
+
+    def beginScope(self):
+        self._scope_stack.append([self.local_vars.copy()])
+
+    def endScope(self):
+        scope = self._scope_stack.pop()
+        self.local_vars = lv = scope[0]
+        v = self.vars
+        v._pop()
+        v._push(lv)
+        # Pop repeat variables, if any
+        i = len(scope) - 1
+        while i:
+            name, value = scope[i]
+            if value is None:
+                del self.repeat_vars[name]
+            else:
+                self.repeat_vars[name] = value
+            i = i - 1
+
+    def setLocal(self, name, value):
+        self.local_vars[name] = value
+
+    def setGlobal(self, name, value):
+        self.global_vars[name] = value
+
+    def setRepeat(self, name, expr):
+        expr = self.evaluate(expr)
+        if not expr:
+            return self._compiler.Iterator(name, (), self)
+        it = self._compiler.Iterator(name, expr, self)
+        old_value = self.repeat_vars.get(name)
+        self._scope_stack[-1].append((name, old_value))
+        self.repeat_vars[name] = it
+        return it
+
+    def evaluate(self, expression,
+                 isinstance=isinstance, StringType=StringType):
+        if isinstance(expression, StringType):
+            expression = self._compiler.compile(expression)
+        __traceback_supplement__ = (
+            TALESTracebackSupplement, self, expression)
+        return expression(self)
+
+    evaluateValue = evaluate
+    evaluateBoolean = evaluate
+
+    def evaluateText(self, expr):
+        text = self.evaluate(expr)
+        if text is Default or text is None:
+            return text
+        if isinstance(text, unicode):
+            return text
+        else:
+            return ustr(text)
+
+    def evaluateStructure(self, expr):
+        return self.evaluate(expr)
+    evaluateStructure = evaluate
+
+    def evaluateMacro(self, expr):
+        # XXX Should return None or a macro definition
+        return self.evaluate(expr)
+    evaluateMacro = evaluate
+
+    def createErrorInfo(self, err, position):
+        return ErrorInfo(err, position)
+
+    def getDefault(self):
+        return Default
+
+    def setSourceFile(self, source_file):
+        self.source_file = source_file
+
+    def setPosition(self, position):
+        self.position = position
+
+    def translate(self, domain, msgid, mapping=None,
+                  context=None, target_language=None, default=None):
+        if context is None:
+            context = self.contexts.get('here')
+        return getGlobalTranslationService().translate(
+            domain, msgid, mapping=mapping,
+            context=context,
+            default=default,
+            target_language=target_language)
+
+class TALESTracebackSupplement:
+    """Implementation of ITracebackSupplement"""
+    def __init__(self, context, expression):
+        self.context = context
+        self.source_url = context.source_file
+        self.line = context.position[0]
+        self.column = context.position[1]
+        self.expression = repr(expression)
+
+    def getInfo(self, as_html=0):
+        import pprint
+        data = self.context.contexts.copy()
+        s = pprint.pformat(data)
+        if not as_html:
+            return '   - Names:\n      %s' % s.replace('\n', '\n      ')
+        else:
+            from cgi import escape
+            return '<b>Names:</b><pre>%s</pre>' % (escape(s))
+
+
+class SimpleExpr:
+    '''Simple example of an expression type handler'''
+    def __init__(self, name, expr, engine):
+        self._name = name
+        self._expr = expr
+    def __call__(self, econtext):
+        return self._name, self._expr
+    def __repr__(self):
+        return '<SimpleExpr %s %s>' % (self._name, `self._expr`)

Added: tracker/vendor/roundup/current/roundup/cgi/PageTemplates/__init__.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/PageTemplates/__init__.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,28 @@
+##############################################################################
+#
+# Copyright (c) 2001 Zope Corporation and Contributors. All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.0 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE
+#
+##############################################################################
+__doc__='''Package wrapper for Page Templates
+
+This wrapper allows the Page Template modules to be segregated in a
+separate package.
+
+$Id: __init__.py,v 1.3 2004/05/21 05:56:46 richard Exp $'''
+__version__='$$'[11:-2]
+
+
+# Placeholder for Zope Product data
+misc_ = {}
+
+def initialize(context):
+    # Import lazily, and defer initialization to the module
+    import ZopePageTemplate
+    ZopePageTemplate.initialize(context)

Added: tracker/vendor/roundup/current/roundup/cgi/TAL/.cvsignore
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/TAL/.cvsignore	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,3 @@
+.path
+*.pyc
+*.pyo

Added: tracker/vendor/roundup/current/roundup/cgi/TAL/DummyEngine.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/TAL/DummyEngine.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,274 @@
+##############################################################################
+#
+# Copyright (c) 2001, 2002 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.0 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+# Modifications for Roundup:
+# 1. commented out ITALES references
+# 2. implemented ustr as str
+"""
+Dummy TALES engine so that I can test out the TAL implementation.
+"""
+
+import re
+import sys
+
+from TALDefs import NAME_RE, TALESError, ErrorInfo
+#from ITALES import ITALESCompiler, ITALESEngine
+#from DocumentTemplate.DT_Util import ustr
+ustr = str
+
+IDomain = None
+if sys.modules.has_key('Zope'):
+    try:
+        from Zope.I18n.ITranslationService import ITranslationService
+        from Zope.I18n.IDomain import IDomain
+    except ImportError:
+        pass
+if IDomain is None:
+    # Before 2.7, or not in Zope
+    class ITranslationService: pass
+    class IDomain: pass
+
+class _Default:
+    pass
+Default = _Default()
+
+name_match = re.compile(r"(?s)(%s):(.*)\Z" % NAME_RE).match
+
+class CompilerError(Exception):
+    pass
+
+class DummyEngine:
+
+    position = None
+    source_file = None
+
+    #__implements__ = ITALESCompiler, ITALESEngine
+
+    def __init__(self, macros=None):
+        if macros is None:
+            macros = {}
+        self.macros = macros
+        dict = {'nothing': None, 'default': Default}
+        self.locals = self.globals = dict
+        self.stack = [dict]
+        self.translationService = DummyTranslationService()
+
+    def getCompilerError(self):
+        return CompilerError
+
+    def getCompiler(self):
+        return self
+
+    def setSourceFile(self, source_file):
+        self.source_file = source_file
+
+    def setPosition(self, position):
+        self.position = position
+
+    def compile(self, expr):
+        return "$%s$" % expr
+
+    def uncompile(self, expression):
+        assert (expression.startswith("$") and expression.endswith("$"),
+            expression)
+        return expression[1:-1]
+
+    def beginScope(self):
+        self.stack.append(self.locals)
+
+    def endScope(self):
+        assert len(self.stack) > 1, "more endScope() than beginScope() calls"
+        self.locals = self.stack.pop()
+
+    def setLocal(self, name, value):
+        if self.locals is self.stack[-1]:
+            # Unmerge this scope's locals from previous scope of first set
+            self.locals = self.locals.copy()
+        self.locals[name] = value
+
+    def setGlobal(self, name, value):
+        self.globals[name] = value
+
+    def evaluate(self, expression):
+        assert (expression.startswith("$") and expression.endswith("$"),
+            expression)
+        expression = expression[1:-1]
+        m = name_match(expression)
+        if m:
+            type, expr = m.group(1, 2)
+        else:
+            type = "path"
+            expr = expression
+        if type in ("string", "str"):
+            return expr
+        if type in ("path", "var", "global", "local"):
+            return self.evaluatePathOrVar(expr)
+        if type == "not":
+            return not self.evaluate(expr)
+        if type == "exists":
+            return self.locals.has_key(expr) or self.globals.has_key(expr)
+        if type == "python":
+            try:
+                return eval(expr, self.globals, self.locals)
+            except:
+                raise TALESError("evaluation error in %s" % `expr`)
+        if type == "position":
+            # Insert the current source file name, line number,
+            # and column offset.
+            if self.position:
+                lineno, offset = self.position
+            else:
+                lineno, offset = None, None
+            return '%s (%s,%s)' % (self.source_file, lineno, offset)
+        raise TALESError("unrecognized expression: " + `expression`)
+
+    def evaluatePathOrVar(self, expr):
+        expr = expr.strip()
+        if self.locals.has_key(expr):
+            return self.locals[expr]
+        elif self.globals.has_key(expr):
+            return self.globals[expr]
+        else:
+            raise TALESError("unknown variable: %s" % `expr`)
+
+    def evaluateValue(self, expr):
+        return self.evaluate(expr)
+
+    def evaluateBoolean(self, expr):
+        return self.evaluate(expr)
+
+    def evaluateText(self, expr):
+        text = self.evaluate(expr)
+        if text is not None and text is not Default:
+            text = ustr(text)
+        return text
+
+    def evaluateStructure(self, expr):
+        # XXX Should return None or a DOM tree
+        return self.evaluate(expr)
+
+    def evaluateSequence(self, expr):
+        # XXX Should return a sequence
+        return self.evaluate(expr)
+
+    def evaluateMacro(self, macroName):
+        assert (macroName.startswith("$") and macroName.endswith("$"),
+            macroName)
+        macroName = macroName[1:-1]
+        file, localName = self.findMacroFile(macroName)
+        if not file:
+            # Local macro
+            macro = self.macros[localName]
+        else:
+            # External macro
+            import driver
+            program, macros = driver.compilefile(file)
+            macro = macros.get(localName)
+            if not macro:
+                raise TALESError("macro %s not found in file %s" %
+                                 (localName, file))
+        return macro
+
+    def findMacroDocument(self, macroName):
+        file, localName = self.findMacroFile(macroName)
+        if not file:
+            return file, localName
+        import driver
+        doc = driver.parsefile(file)
+        return doc, localName
+
+    def findMacroFile(self, macroName):
+        if not macroName:
+            raise TALESError("empty macro name")
+        i = macroName.rfind('/')
+        if i < 0:
+            # No slash -- must be a locally defined macro
+            return None, macroName
+        else:
+            # Up to last slash is the filename
+            fileName = macroName[:i]
+            localName = macroName[i+1:]
+            return fileName, localName
+
+    def setRepeat(self, name, expr):
+        seq = self.evaluateSequence(expr)
+        return Iterator(name, seq, self)
+
+    def createErrorInfo(self, err, position):
+        return ErrorInfo(err, position)
+
+    def getDefault(self):
+        return Default
+
+    def translate(self, domain, msgid, mapping, default=None):
+        return self.translationService.translate(domain, msgid, mapping,
+                                                 default=default)
+
+
+class Iterator:
+
+    # This is not an implementation of a Python iterator.  The next()
+    # method returns true or false to indicate whether another item is
+    # available; if there is another item, the iterator instance calls
+    # setLocal() on the evaluation engine passed to the constructor.
+
+    def __init__(self, name, seq, engine):
+        self.name = name
+        self.seq = seq
+        self.engine = engine
+        self.nextIndex = 0
+
+    def next(self):
+        i = self.nextIndex
+        try:
+            item = self.seq[i]
+        except IndexError:
+            return 0
+        self.nextIndex = i+1
+        self.engine.setLocal(self.name, item)
+        return 1
+
+class DummyDomain:
+    __implements__ = IDomain
+
+    def translate(self, msgid, mapping=None, context=None,
+                  target_language=None, default=None):
+        # This is a fake translation service which simply uppercases non
+        # ${name} placeholder text in the message id.
+        #
+        # First, transform a string with ${name} placeholders into a list of
+        # substrings.  Then upcase everything but the placeholders, then glue
+        # things back together.
+
+        # simulate an unknown msgid by returning None
+        if msgid == "don't translate me":
+            text = default
+        else:
+            text = msgid.upper()
+
+        def repl(m, mapping=mapping):
+            return ustr(mapping[m.group(m.lastindex).lower()])
+        cre = re.compile(r'\$(?:(%s)|\{(%s)\})' % (NAME_RE, NAME_RE))
+        return cre.sub(repl, text)
+
+class DummyTranslationService:
+    __implements__ = ITranslationService
+
+    def translate(self, domain, msgid, mapping=None, context=None,
+                  target_language=None, default=None):
+        return self.getDomain(domain).translate(msgid, mapping, context,
+                                                target_language,
+                                                default=default)
+
+    def getDomain(self, domain):
+        return DummyDomain()

Added: tracker/vendor/roundup/current/roundup/cgi/TAL/HTMLParser.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/TAL/HTMLParser.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,402 @@
+"""A parser for HTML and XHTML."""
+
+# This file is based on sgmllib.py, but the API is slightly different.
+
+# XXX There should be a way to distinguish between PCDATA (parsed
+# character data -- the normal case), RCDATA (replaceable character
+# data -- only char and entity references and end tags are special)
+# and CDATA (character data -- only end tags are special).
+
+
+import markupbase
+import re
+
+# Regular expressions used for parsing
+
+interesting_normal = re.compile('[&<]')
+interesting_cdata = re.compile(r'<(/|\Z)')
+incomplete = re.compile('&[a-zA-Z#]')
+
+entityref = re.compile('&([a-zA-Z][-.a-zA-Z0-9]*)[^a-zA-Z0-9]')
+charref = re.compile('&#(?:[0-9]+|[xX][0-9a-fA-F]+)[^0-9a-fA-F]')
+
+starttagopen = re.compile('<[a-zA-Z]')
+piclose = re.compile('>')
+endtagopen = re.compile('</')
+commentclose = re.compile(r'--\s*>')
+tagfind = re.compile('[a-zA-Z][-.a-zA-Z0-9:_]*')
+attrfind = re.compile(
+    r'\s*([a-zA-Z_][-.:a-zA-Z_0-9]*)(\s*=\s*'
+    r'(\'[^\']*\'|"[^"]*"|[-a-zA-Z0-9./:;+*%?!&$\(\)_#=~]*))?')
+
+locatestarttagend = re.compile(r"""
+  <[a-zA-Z][-.a-zA-Z0-9:_]*          # tag name
+  (?:\s+                             # whitespace before attribute name
+    (?:[a-zA-Z_][-.:a-zA-Z0-9_]*     # attribute name
+      (?:\s*=\s*                     # value indicator
+        (?:'[^']*'                   # LITA-enclosed value
+          |\"[^\"]*\"                # LIT-enclosed value
+          |[^'\">\s]+                # bare value
+         )
+       )?
+     )
+   )*
+  \s*                                # trailing whitespace
+""", re.VERBOSE)
+endendtag = re.compile('>')
+endtagfind = re.compile('</\s*([a-zA-Z][-.a-zA-Z0-9:_]*)\s*>')
+
+
+class HTMLParseError(Exception):
+    """Exception raised for all parse errors."""
+
+    def __init__(self, msg, position=(None, None)):
+        assert msg
+        self.msg = msg
+        self.lineno = position[0]
+        self.offset = position[1]
+
+    def __str__(self):
+        result = self.msg
+        if self.lineno is not None:
+            result = result + ", at line %d" % self.lineno
+        if self.offset is not None:
+            result = result + ", column %d" % (self.offset + 1)
+        return result
+
+
+def _contains_at(s, sub, pos):
+    return s[pos:pos+len(sub)] == sub
+
+
+class HTMLParser(markupbase.ParserBase):
+    """Find tags and other markup and call handler functions.
+
+    Usage:
+        p = HTMLParser()
+        p.feed(data)
+        ...
+        p.close()
+
+    Start tags are handled by calling self.handle_starttag() or
+    self.handle_startendtag(); end tags by self.handle_endtag().  The
+    data between tags is passed from the parser to the derived class
+    by calling self.handle_data() with the data as argument (the data
+    may be split up in arbitrary chunks).  Entity references are
+    passed by calling self.handle_entityref() with the entity
+    reference as the argument.  Numeric character references are
+    passed to self.handle_charref() with the string containing the
+    reference as the argument.
+    """
+
+    CDATA_CONTENT_ELEMENTS = ("script", "style")
+
+
+    def __init__(self):
+        """Initialize and reset this instance."""
+        self.reset()
+
+    def reset(self):
+        """Reset this instance.  Loses all unprocessed data."""
+        self.rawdata = ''
+        self.stack = []
+        self.lasttag = '???'
+        self.interesting = interesting_normal
+        markupbase.ParserBase.reset(self)
+
+    def feed(self, data):
+        """Feed data to the parser.
+
+        Call this as often as you want, with as little or as much text
+        as you want (may include '\n').
+        """
+        self.rawdata = self.rawdata + data
+        self.goahead(0)
+
+    def close(self):
+        """Handle any buffered data."""
+        self.goahead(1)
+
+    def error(self, message):
+        raise HTMLParseError(message, self.getpos())
+
+    __starttag_text = None
+
+    def get_starttag_text(self):
+        """Return full source of start tag: '<...>'."""
+        return self.__starttag_text
+
+    cdata_endtag = None
+
+    def set_cdata_mode(self, endtag=None):
+        self.cdata_endtag = endtag
+        self.interesting = interesting_cdata
+
+    def clear_cdata_mode(self):
+        self.cdata_endtag = None
+        self.interesting = interesting_normal
+
+    # Internal -- handle data as far as reasonable.  May leave state
+    # and data to be processed by a subsequent call.  If 'end' is
+    # true, force handling all data as if followed by EOF marker.
+    def goahead(self, end):
+        rawdata = self.rawdata
+        i = 0
+        n = len(rawdata)
+        while i < n:
+            match = self.interesting.search(rawdata, i) # < or &
+            if match:
+                j = match.start()
+            else:
+                j = n
+            if i < j: self.handle_data(rawdata[i:j])
+            i = self.updatepos(i, j)
+            if i == n: break
+            if rawdata[i] == '<':
+                if starttagopen.match(rawdata, i): # < + letter
+                    k = self.parse_starttag(i)
+                elif endtagopen.match(rawdata, i): # </
+                    k = self.parse_endtag(i)
+                elif _contains_at(rawdata, "<!--", i): # <!--
+                    k = self.parse_comment(i)
+                elif _contains_at(rawdata, "<!", i): # <!
+                    k = self.parse_declaration(i)
+                elif _contains_at(rawdata, "<?", i): # <?
+                    k = self.parse_pi(i)
+                elif _contains_at(rawdata, "<?", i): # <!
+                    k = self.parse_declaration(i)
+                elif (i + 1) < n:
+                    self.handle_data("<")
+                    k = i + 1
+                else:
+                    break
+                if k < 0:
+                    if end:
+                        self.error("EOF in middle of construct")
+                    break
+                i = self.updatepos(i, k)
+            elif rawdata[i:i+2] == "&#":
+                match = charref.match(rawdata, i)
+                if match:
+                    name = match.group()[2:-1]
+                    self.handle_charref(name)
+                    k = match.end()
+                    if rawdata[k-1] != ';':
+                        k = k - 1
+                    i = self.updatepos(i, k)
+                    continue
+                else:
+                    break
+            elif rawdata[i] == '&':
+                match = entityref.match(rawdata, i)
+                if match:
+                    name = match.group(1)
+                    self.handle_entityref(name)
+                    k = match.end()
+                    if rawdata[k-1] != ';':
+                        k = k - 1
+                    i = self.updatepos(i, k)
+                    continue
+                match = incomplete.match(rawdata, i)
+                if match:
+                    # match.group() will contain at least 2 chars
+                    rest = rawdata[i:]
+                    if end and match.group() == rest:
+                        self.error("EOF in middle of entity or char ref")
+                    # incomplete
+                    break
+                elif (i + 1) < n:
+                    # not the end of the buffer, and can't be confused
+                    # with some other construct
+                    self.handle_data("&")
+                    i = self.updatepos(i, i + 1)
+                else:
+                    break
+            else:
+                assert 0, "interesting.search() lied"
+        # end while
+        if end and i < n:
+            self.handle_data(rawdata[i:n])
+            i = self.updatepos(i, n)
+        self.rawdata = rawdata[i:]
+
+    # Internal -- parse comment, return end or -1 if not terminated
+    def parse_comment(self, i, report=1):
+        rawdata = self.rawdata
+        assert rawdata[i:i+4] == '<!--', 'unexpected call to parse_comment()'
+        match = commentclose.search(rawdata, i+4)
+        if not match:
+            return -1
+        if report:
+            j = match.start()
+            self.handle_comment(rawdata[i+4: j])
+        j = match.end()
+        return j
+
+    # Internal -- parse processing instr, return end or -1 if not terminated
+    def parse_pi(self, i):
+        rawdata = self.rawdata
+        assert rawdata[i:i+2] == '<?', 'unexpected call to parse_pi()'
+        match = piclose.search(rawdata, i+2) # >
+        if not match:
+            return -1
+        j = match.start()
+        self.handle_pi(rawdata[i+2: j])
+        j = match.end()
+        return j
+
+    # Internal -- handle starttag, return end or -1 if not terminated
+    def parse_starttag(self, i):
+        self.__starttag_text = None
+        endpos = self.check_for_whole_start_tag(i)
+        if endpos < 0:
+            return endpos
+        rawdata = self.rawdata
+        self.__starttag_text = rawdata[i:endpos]
+
+        # Now parse the data between i+1 and j into a tag and attrs
+        attrs = []
+        match = tagfind.match(rawdata, i+1)
+        assert match, 'unexpected call to parse_starttag()'
+        k = match.end()
+        self.lasttag = tag = rawdata[i+1:k].lower()
+
+        while k < endpos:
+            m = attrfind.match(rawdata, k)
+            if not m:
+                break
+            attrname, rest, attrvalue = m.group(1, 2, 3)
+            if not rest:
+                attrvalue = None
+            elif attrvalue[:1] == '\'' == attrvalue[-1:] or \
+                 attrvalue[:1] == '"' == attrvalue[-1:]:
+                attrvalue = attrvalue[1:-1]
+                attrvalue = self.unescape(attrvalue)
+            attrs.append((attrname.lower(), attrvalue))
+            k = m.end()
+
+        end = rawdata[k:endpos].strip()
+        if end not in (">", "/>"):
+            lineno, offset = self.getpos()
+            if "\n" in self.__starttag_text:
+                lineno = lineno + self.__starttag_text.count("\n")
+                offset = len(self.__starttag_text) \
+                         - self.__starttag_text.rfind("\n")
+            else:
+                offset = offset + len(self.__starttag_text)
+            self.error("junk characters in start tag: %s"
+                       % `rawdata[k:endpos][:20]`)
+        if end[-2:] == '/>':
+            # XHTML-style empty tag: <span attr="value" />
+            self.handle_startendtag(tag, attrs)
+        else:
+            self.handle_starttag(tag, attrs)
+            if tag in self.CDATA_CONTENT_ELEMENTS:
+                self.set_cdata_mode(tag)
+        return endpos
+
+    # Internal -- check to see if we have a complete starttag; return end
+    # or -1 if incomplete.
+    def check_for_whole_start_tag(self, i):
+        rawdata = self.rawdata
+        m = locatestarttagend.match(rawdata, i)
+        if m:
+            j = m.end()
+            next = rawdata[j:j+1]
+            if next == ">":
+                return j + 1
+            if next == "/":
+                s = rawdata[j:j+2]
+                if s == "/>":
+                    return j + 2
+                if s == "/":
+                    # buffer boundary
+                    return -1
+                # else bogus input
+                self.updatepos(i, j + 1)
+                self.error("malformed empty start tag")
+            if next == "":
+                # end of input
+                return -1
+            if next in ("abcdefghijklmnopqrstuvwxyz=/"
+                        "ABCDEFGHIJKLMNOPQRSTUVWXYZ"):
+                # end of input in or before attribute value, or we have the
+                # '/' from a '/>' ending
+                return -1
+            self.updatepos(i, j)
+            self.error("malformed start tag")
+        raise AssertionError("we should not get here!")
+
+    # Internal -- parse endtag, return end or -1 if incomplete
+    def parse_endtag(self, i):
+        rawdata = self.rawdata
+        assert rawdata[i:i+2] == "</", "unexpected call to parse_endtag"
+        match = endendtag.search(rawdata, i+1) # >
+        if not match:
+            return -1
+        j = match.end()
+        match = endtagfind.match(rawdata, i) # </ + tag + >
+        if not match:
+            self.error("bad end tag: %s" % `rawdata[i:j]`)
+        tag = match.group(1).lower()
+        if (  self.cdata_endtag is not None
+              and tag != self.cdata_endtag):
+            # Should be a mismatched end tag, but we'll treat it
+            # as text anyway, since most HTML authors aren't
+            # interested in the finer points of syntax.
+            self.handle_data(match.group(0))
+        else:
+            self.handle_endtag(tag)
+            self.clear_cdata_mode()
+        return j
+
+    # Overridable -- finish processing of start+end tag: <tag.../>
+    def handle_startendtag(self, tag, attrs):
+        self.handle_starttag(tag, attrs)
+        self.handle_endtag(tag)
+
+    # Overridable -- handle start tag
+    def handle_starttag(self, tag, attrs):
+        pass
+
+    # Overridable -- handle end tag
+    def handle_endtag(self, tag):
+        pass
+
+    # Overridable -- handle character reference
+    def handle_charref(self, name):
+        pass
+
+    # Overridable -- handle entity reference
+    def handle_entityref(self, name):
+        pass
+
+    # Overridable -- handle data
+    def handle_data(self, data):
+        pass
+
+    # Overridable -- handle comment
+    def handle_comment(self, data):
+        pass
+
+    # Overridable -- handle declaration
+    def handle_decl(self, decl):
+        pass
+
+    # Overridable -- handle processing instruction
+    def handle_pi(self, data):
+        pass
+
+    def unknown_decl(self, data):
+        self.error("unknown declaration: " + `data`)
+
+    # Internal -- helper to remove special character quoting
+    def unescape(self, s):
+        if '&' not in s:
+            return s
+        s = s.replace("&lt;", "<")
+        s = s.replace("&gt;", ">")
+        s = s.replace("&apos;", "'")
+        s = s.replace("&quot;", '"')
+        s = s.replace("&amp;", "&") # Must be last
+        return s

Added: tracker/vendor/roundup/current/roundup/cgi/TAL/HTMLTALParser.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/TAL/HTMLTALParser.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,315 @@
+##############################################################################
+#
+# Copyright (c) 2001, 2002 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.0 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+"""
+Parse HTML and compile to TALInterpreter intermediate code.
+"""
+
+import sys
+
+from TALGenerator import TALGenerator
+from HTMLParser import HTMLParser, HTMLParseError
+from TALDefs import \
+     ZOPE_METAL_NS, ZOPE_TAL_NS, ZOPE_I18N_NS, METALError, TALError, I18NError
+
+BOOLEAN_HTML_ATTRS = [
+    # List of Boolean attributes in HTML that may be given in
+    # minimized form (e.g. <img ismap> rather than <img ismap="">)
+    # From http://www.w3.org/TR/xhtml1/#guidelines (C.10)
+    "compact", "nowrap", "ismap", "declare", "noshade", "checked",
+    "disabled", "readonly", "multiple", "selected", "noresize",
+    "defer"
+    ]
+
+EMPTY_HTML_TAGS = [
+    # List of HTML tags with an empty content model; these are
+    # rendered in minimized form, e.g. <img />.
+    # From http://www.w3.org/TR/xhtml1/#dtds
+    "base", "meta", "link", "hr", "br", "param", "img", "area",
+    "input", "col", "basefont", "isindex", "frame",
+    ]
+
+PARA_LEVEL_HTML_TAGS = [
+    # List of HTML elements that close open paragraph-level elements
+    # and are themselves paragraph-level.
+    "h1", "h2", "h3", "h4", "h5", "h6", "p",
+    ]
+
+BLOCK_CLOSING_TAG_MAP = {
+    "tr": ("tr", "td", "th"),
+    "td": ("td", "th"),
+    "th": ("td", "th"),
+    "li": ("li",),
+    "dd": ("dd", "dt"),
+    "dt": ("dd", "dt"),
+    }
+
+BLOCK_LEVEL_HTML_TAGS = [
+    # List of HTML tags that denote larger sections than paragraphs.
+    "blockquote", "table", "tr", "th", "td", "thead", "tfoot", "tbody",
+    "noframe", "ul", "ol", "li", "dl", "dt", "dd", "div",
+    ]
+
+TIGHTEN_IMPLICIT_CLOSE_TAGS = (PARA_LEVEL_HTML_TAGS
+                               + BLOCK_CLOSING_TAG_MAP.keys())
+
+
+class NestingError(HTMLParseError):
+    """Exception raised when elements aren't properly nested."""
+
+    def __init__(self, tagstack, endtag, position=(None, None)):
+        self.endtag = endtag
+        if tagstack:
+            if len(tagstack) == 1:
+                msg = ('Open tag <%s> does not match close tag </%s>'
+                       % (tagstack[0], endtag))
+            else:
+                msg = ('Open tags <%s> do not match close tag </%s>'
+                       % ('>, <'.join(tagstack), endtag))
+        else:
+            msg = 'No tags are open to match </%s>' % endtag
+        HTMLParseError.__init__(self, msg, position)
+
+class EmptyTagError(NestingError):
+    """Exception raised when empty elements have an end tag."""
+
+    def __init__(self, tag, position=(None, None)):
+        self.tag = tag
+        msg = 'Close tag </%s> should be removed' % tag
+        HTMLParseError.__init__(self, msg, position)
+
+class OpenTagError(NestingError):
+    """Exception raised when a tag is not allowed in another tag."""
+
+    def __init__(self, tagstack, tag, position=(None, None)):
+        self.tag = tag
+        msg = 'Tag <%s> is not allowed in <%s>' % (tag, tagstack[-1])
+        HTMLParseError.__init__(self, msg, position)
+
+class HTMLTALParser(HTMLParser):
+
+    # External API
+
+    def __init__(self, gen=None):
+        HTMLParser.__init__(self)
+        if gen is None:
+            gen = TALGenerator(xml=0)
+        self.gen = gen
+        self.tagstack = []
+        self.nsstack = []
+        self.nsdict = {'tal': ZOPE_TAL_NS,
+                       'metal': ZOPE_METAL_NS,
+                       'i18n': ZOPE_I18N_NS,
+                       }
+
+    def parseFile(self, file):
+        f = open(file)
+        data = f.read()
+        f.close()
+        try:
+            self.parseString(data)
+        except TALError, e:
+            e.setFile(file)
+            raise
+
+    def parseString(self, data):
+        self.feed(data)
+        self.close()
+        while self.tagstack:
+            self.implied_endtag(self.tagstack[-1], 2)
+        assert self.nsstack == [], self.nsstack
+
+    def getCode(self):
+        return self.gen.getCode()
+
+    def getWarnings(self):
+        return ()
+
+    # Overriding HTMLParser methods
+
+    def handle_starttag(self, tag, attrs):
+        self.close_para_tags(tag)
+        self.scan_xmlns(attrs)
+        tag, attrlist, taldict, metaldict, i18ndict \
+             = self.process_ns(tag, attrs)
+        if tag in EMPTY_HTML_TAGS and taldict.get("content"):
+            raise TALError(
+                "empty HTML tags cannot use tal:content: %s" % `tag`,
+                self.getpos())
+        self.tagstack.append(tag)
+        self.gen.emitStartElement(tag, attrlist, taldict, metaldict, i18ndict,
+                                  self.getpos())
+        if tag in EMPTY_HTML_TAGS:
+            self.implied_endtag(tag, -1)
+
+    def handle_startendtag(self, tag, attrs):
+        self.close_para_tags(tag)
+        self.scan_xmlns(attrs)
+        tag, attrlist, taldict, metaldict, i18ndict \
+             = self.process_ns(tag, attrs)
+        if taldict.get("content"):
+            if tag in EMPTY_HTML_TAGS:
+                raise TALError(
+                    "empty HTML tags cannot use tal:content: %s" % `tag`,
+                    self.getpos())
+            self.gen.emitStartElement(tag, attrlist, taldict, metaldict,
+                                      i18ndict, self.getpos())
+            self.gen.emitEndElement(tag, implied=-1)
+        else:
+            self.gen.emitStartElement(tag, attrlist, taldict, metaldict,
+                                      i18ndict, self.getpos(), isend=1)
+        self.pop_xmlns()
+
+    def handle_endtag(self, tag):
+        if tag in EMPTY_HTML_TAGS:
+            # </img> etc. in the source is an error
+            raise EmptyTagError(tag, self.getpos())
+        self.close_enclosed_tags(tag)
+        self.gen.emitEndElement(tag)
+        self.pop_xmlns()
+        self.tagstack.pop()
+
+    def close_para_tags(self, tag):
+        if tag in EMPTY_HTML_TAGS:
+            return
+        close_to = -1
+        if BLOCK_CLOSING_TAG_MAP.has_key(tag):
+            blocks_to_close = BLOCK_CLOSING_TAG_MAP[tag]
+            for i in range(len(self.tagstack)):
+                t = self.tagstack[i]
+                if t in blocks_to_close:
+                    if close_to == -1:
+                        close_to = i
+                elif t in BLOCK_LEVEL_HTML_TAGS:
+                    close_to = -1
+        elif tag in PARA_LEVEL_HTML_TAGS + BLOCK_LEVEL_HTML_TAGS:
+            i = len(self.tagstack) - 1
+            while i >= 0:
+                closetag = self.tagstack[i]
+                if closetag in BLOCK_LEVEL_HTML_TAGS:
+                    break
+                if closetag in PARA_LEVEL_HTML_TAGS:
+                    if closetag != "p":
+                        raise OpenTagError(self.tagstack, tag, self.getpos())
+                    close_to = i
+                i = i - 1
+        if close_to >= 0:
+            while len(self.tagstack) > close_to:
+                self.implied_endtag(self.tagstack[-1], 1)
+
+    def close_enclosed_tags(self, tag):
+        if tag not in self.tagstack:
+            raise NestingError(self.tagstack, tag, self.getpos())
+        while tag != self.tagstack[-1]:
+            self.implied_endtag(self.tagstack[-1], 1)
+        assert self.tagstack[-1] == tag
+
+    def implied_endtag(self, tag, implied):
+        assert tag == self.tagstack[-1]
+        assert implied in (-1, 1, 2)
+        isend = (implied < 0)
+        if tag in TIGHTEN_IMPLICIT_CLOSE_TAGS:
+            # Pick out trailing whitespace from the program, and
+            # insert the close tag before the whitespace.
+            white = self.gen.unEmitWhitespace()
+        else:
+            white = None
+        self.gen.emitEndElement(tag, isend=isend, implied=implied)
+        if white:
+            self.gen.emitRawText(white)
+        self.tagstack.pop()
+        self.pop_xmlns()
+
+    def handle_charref(self, name):
+        self.gen.emitRawText("&#%s;" % name)
+
+    def handle_entityref(self, name):
+        self.gen.emitRawText("&%s;" % name)
+
+    def handle_data(self, data):
+        self.gen.emitRawText(data)
+
+    def handle_comment(self, data):
+        self.gen.emitRawText("<!--%s-->" % data)
+
+    def handle_decl(self, data):
+        self.gen.emitRawText("<!%s>" % data)
+
+    def handle_pi(self, data):
+        self.gen.emitRawText("<?%s>" % data)
+
+    # Internal thingies
+
+    def scan_xmlns(self, attrs):
+        nsnew = {}
+        for key, value in attrs:
+            if key.startswith("xmlns:"):
+                nsnew[key[6:]] = value
+        if nsnew:
+            self.nsstack.append(self.nsdict)
+            self.nsdict = self.nsdict.copy()
+            self.nsdict.update(nsnew)
+        else:
+            self.nsstack.append(self.nsdict)
+
+    def pop_xmlns(self):
+        self.nsdict = self.nsstack.pop()
+
+    def fixname(self, name):
+        if ':' in name:
+            prefix, suffix = name.split(':', 1)
+            if prefix == 'xmlns':
+                nsuri = self.nsdict.get(suffix)
+                if nsuri in (ZOPE_TAL_NS, ZOPE_METAL_NS, ZOPE_I18N_NS):
+                    return name, name, prefix
+            else:
+                nsuri = self.nsdict.get(prefix)
+                if nsuri == ZOPE_TAL_NS:
+                    return name, suffix, 'tal'
+                elif nsuri == ZOPE_METAL_NS:
+                    return name, suffix,  'metal'
+                elif nsuri == ZOPE_I18N_NS:
+                    return name, suffix, 'i18n'
+        return name, name, 0
+
+    def process_ns(self, name, attrs):
+        attrlist = []
+        taldict = {}
+        metaldict = {}
+        i18ndict = {}
+        name, namebase, namens = self.fixname(name)
+        for item in attrs:
+            key, value = item
+            key, keybase, keyns = self.fixname(key)
+            ns = keyns or namens # default to tag namespace
+            if ns and ns != 'unknown':
+                item = (key, value, ns)
+            if ns == 'tal':
+                if taldict.has_key(keybase):
+                    raise TALError("duplicate TAL attribute " +
+                                   `keybase`, self.getpos())
+                taldict[keybase] = value
+            elif ns == 'metal':
+                if metaldict.has_key(keybase):
+                    raise METALError("duplicate METAL attribute " +
+                                     `keybase`, self.getpos())
+                metaldict[keybase] = value
+            elif ns == 'i18n':
+                if i18ndict.has_key(keybase):
+                    raise I18NError("duplicate i18n attribute " +
+                                    `keybase`, self.getpos())
+                i18ndict[keybase] = value
+            attrlist.append(item)
+        if namens in ('metal', 'tal'):
+            taldict['tal tag'] = namens
+        return name, attrlist, taldict, metaldict, i18ndict

Added: tracker/vendor/roundup/current/roundup/cgi/TAL/README.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/TAL/README.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,97 @@
+TAL - Template Attribute Language
+---------------------------------
+
+This is an implementation of TAL, the Zope Template Attribute
+Language.  For TAL, see the Zope Presentation Templates ZWiki:
+
+    http://dev.zope.org/Wikis/DevSite/Projects/ZPT/FrontPage
+
+It is not a Zope product nor is it designed exclusively to run inside
+of Zope, but if you have a Zope checkout that includes
+Products/ParsedXML, its Expat parser will be used.
+
+Prerequisites
+-------------
+
+You need:
+
+- A recent checkout of Zope2; don't forget to run the wo_pcgi.py
+  script to compile everything.  (See above -- this is now optional.)
+
+- A recent checkout of the Zope2 product ParsedXML, accessible
+  throught <Zope2>/lib/python/Products/ParsedXML; don't forget to run
+  the setup.py script to compiles Expat.  (Again, optional.)
+
+- Python 1.5.2; the driver script refuses to work with other versions
+  unless you specify the -n option; this is done so that I don't
+  accidentally use Python 2.x features.
+
+- Create a .path file containing proper module search path; it should
+  point the <Zope2>/lib/python directory that you want to use.
+
+How To Play
+-----------
+
+(Don't forget to edit .path, see above!)
+
+The script driver.py takes an XML file with TAL markup as argument and
+writes the expanded version to standard output.  The filename argument
+defaults to tests/input/test01.xml.
+
+Regression test
+---------------
+
+There are unit test suites in the 'tests' subdirectory; these can be
+run with tests/run.py.  This should print the testcase names plus
+progress info, followed by a final line saying "OK".  It requires that
+../unittest.py exists.
+
+There are a number of test files in the 'tests' subdirectory, named
+tests/input/test<number>.xml and tests/input/test<number>.html.  The
+Python script ./runtest.py calls driver.main() for each test file, and
+should print "<file> OK" for each one.  These tests are also run as
+part of the unit test suites, so tests/run.py is all you need.
+
+What's Here
+-----------
+
+DummyEngine.py		simple-minded TALES execution engine
+TALInterpreter.py	class to interpret intermediate code
+TALGenerator.py		class to generate intermediate code
+XMLParser.py		base class to parse XML, avoiding DOM
+TALParser.py		class to parse XML with TAL into intermediate code
+HTMLTALParser.py	class to parse HTML with TAL into intermediate code
+HTMLParser.py		HTML-parsing base class
+driver.py		script to demonstrate TAL expansion
+timer.py		script to time various processing phases
+setpath.py		hack to set sys.path and import ZODB
+__init__.py		empty file that makes this directory a package
+runtest.py		Python script to run file-comparison tests
+ndiff.py		helper for runtest.py to produce diffs
+tests/			drectory with test files and output
+tests/run.py		Python script to run all tests
+
+Author and License
+------------------
+
+This code is written by Guido van Rossum (project lead), Fred Drake,
+and Tim Peters.  It is owned by Digital Creations and can be
+redistributed under the Zope Public License.
+
+TO DO
+-----
+
+(See also http://www.zope.org/Members/jim/ZPTIssueTracker .)
+
+- Need to remove leading whitespace and newline when omitting an
+  element (either through tal:replace with a value of nothing or
+  tal:condition with a false condition).
+
+- Empty TAL/METAL attributes are ignored: tal:replace="" is ignored
+  rather than causing an error.
+
+- HTMLTALParser.py and TALParser.py are silly names.  Should be
+  HTMLTALCompiler.py and XMLTALCompiler.py (or maybe shortened,
+  without "TAL"?)
+
+- Should we preserve case of tags and attribute names in HTML?

Added: tracker/vendor/roundup/current/roundup/cgi/TAL/TALDefs.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/TAL/TALDefs.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,193 @@
+##############################################################################
+#
+# Copyright (c) 2001, 2002 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.0 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+# Modifications for Roundup:
+# 1. commented out ITALES references
+"""
+Common definitions used by TAL and METAL compilation an transformation.
+"""
+
+from types import ListType, TupleType
+
+#from ITALES import ITALESErrorInfo
+
+TAL_VERSION = "1.4"
+
+XML_NS = "http://www.w3.org/XML/1998/namespace" # URI for XML namespace
+XMLNS_NS = "http://www.w3.org/2000/xmlns/" # URI for XML NS declarations
+
+ZOPE_TAL_NS = "http://xml.zope.org/namespaces/tal"
+ZOPE_METAL_NS = "http://xml.zope.org/namespaces/metal"
+ZOPE_I18N_NS = "http://xml.zope.org/namespaces/i18n"
+
+# This RE must exactly match the expression of the same name in the
+# zope.i18n.simpletranslationservice module:
+NAME_RE = "[a-zA-Z_][-a-zA-Z0-9_]*"
+
+KNOWN_METAL_ATTRIBUTES = [
+    "define-macro",
+    "use-macro",
+    "define-slot",
+    "fill-slot",
+    "slot",
+    ]
+
+KNOWN_TAL_ATTRIBUTES = [
+    "define",
+    "condition",
+    "content",
+    "replace",
+    "repeat",
+    "attributes",
+    "on-error",
+    "omit-tag",
+    "tal tag",
+    ]
+
+KNOWN_I18N_ATTRIBUTES = [
+    "translate",
+    "domain",
+    "target",
+    "source",
+    "attributes",
+    "data",
+    "name",
+    ]
+
+class TALError(Exception):
+
+    def __init__(self, msg, position=(None, None)):
+        assert msg != ""
+        self.msg = msg
+        self.lineno = position[0]
+        self.offset = position[1]
+        self.filename = None
+
+    def setFile(self, filename):
+        self.filename = filename
+
+    def __str__(self):
+        result = self.msg
+        if self.lineno is not None:
+            result = result + ", at line %d" % self.lineno
+        if self.offset is not None:
+            result = result + ", column %d" % (self.offset + 1)
+        if self.filename is not None:
+            result = result + ', in file %s' % self.filename
+        return result
+
+class METALError(TALError):
+    pass
+
+class TALESError(TALError):
+    pass
+
+class I18NError(TALError):
+    pass
+
+
+class ErrorInfo:
+
+    #__implements__ = ITALESErrorInfo
+
+    def __init__(self, err, position=(None, None)):
+        if isinstance(err, Exception):
+            self.type = err.__class__
+            self.value = err
+        else:
+            self.type = err
+            self.value = None
+        self.lineno = position[0]
+        self.offset = position[1]
+
+
+
+import re
+_attr_re = re.compile(r"\s*([^\s]+)\s+([^\s].*)\Z", re.S)
+_subst_re = re.compile(r"\s*(?:(text|structure)\s+)?(.*)\Z", re.S)
+del re
+
+def parseAttributeReplacements(arg, xml):
+    dict = {}
+    for part in splitParts(arg):
+        m = _attr_re.match(part)
+        if not m:
+            raise TALError("Bad syntax in attributes: " + `part`)
+        name, expr = m.group(1, 2)
+        if not xml:
+            name = name.lower()
+        if dict.has_key(name):
+            raise TALError("Duplicate attribute name in attributes: " + `part`)
+        dict[name] = expr
+    return dict
+
+def parseSubstitution(arg, position=(None, None)):
+    m = _subst_re.match(arg)
+    if not m:
+        raise TALError("Bad syntax in substitution text: " + `arg`, position)
+    key, expr = m.group(1, 2)
+    if not key:
+        key = "text"
+    return key, expr
+
+def splitParts(arg):
+    # Break in pieces at undoubled semicolons and
+    # change double semicolons to singles:
+    arg = arg.replace(";;", "\0")
+    parts = arg.split(';')
+    parts = [p.replace("\0", ";") for p in parts]
+    if len(parts) > 1 and not parts[-1].strip():
+        del parts[-1] # It ended in a semicolon
+    return parts
+
+def isCurrentVersion(program):
+    version = getProgramVersion(program)
+    return version == TAL_VERSION
+
+def getProgramMode(program):
+    version = getProgramVersion(program)
+    if (version == TAL_VERSION and isinstance(program[1], TupleType) and
+        len(program[1]) == 2):
+        opcode, mode = program[1]
+        if opcode == "mode":
+            return mode
+    return None
+
+def getProgramVersion(program):
+    if (len(program) >= 2 and
+        isinstance(program[0], TupleType) and len(program[0]) == 2):
+        opcode, version = program[0]
+        if opcode == "version":
+            return version
+    return None
+
+import re
+_ent1_re = re.compile('&(?![A-Z#])', re.I)
+_entch_re = re.compile('&([A-Z][A-Z0-9]*)(?![A-Z0-9;])', re.I)
+_entn1_re = re.compile('&#(?![0-9X])', re.I)
+_entnx_re = re.compile('&(#X[A-F0-9]*)(?![A-F0-9;])', re.I)
+_entnd_re = re.compile('&(#[0-9][0-9]*)(?![0-9;])')
+del re
+
+def attrEscape(s):
+    """Replace special characters '&<>' by character entities,
+    except when '&' already begins a syntactically valid entity."""
+    s = _ent1_re.sub('&amp;', s)
+    s = _entch_re.sub(r'&amp;\1', s)
+    s = _entn1_re.sub('&amp;#', s)
+    s = _entnx_re.sub(r'&amp;\1', s)
+    s = _entnd_re.sub(r'&amp;\1', s)
+    s = s.replace('<', '&lt;')
+    s = s.replace('>', '&gt;')
+    s = s.replace('"', '&quot;')
+    return s

Added: tracker/vendor/roundup/current/roundup/cgi/TAL/TALGenerator.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/TAL/TALGenerator.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,876 @@
+##############################################################################
+#
+# Copyright (c) 2001, 2002 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.0 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+"""
+Code generator for TALInterpreter intermediate code.
+"""
+
+import re
+import cgi
+
+import TALDefs
+
+from TALDefs import NAME_RE, TAL_VERSION
+from TALDefs import I18NError, METALError, TALError
+from TALDefs import parseSubstitution
+from TranslationContext import TranslationContext, DEFAULT_DOMAIN
+
+I18N_REPLACE = 1
+I18N_CONTENT = 2
+I18N_EXPRESSION = 3
+
+_name_rx = re.compile(NAME_RE)
+
+
+class TALGenerator:
+
+    inMacroUse = 0
+    inMacroDef = 0
+    source_file = None
+
+    def __init__(self, expressionCompiler=None, xml=1, source_file=None):
+        if not expressionCompiler:
+            from DummyEngine import DummyEngine
+            expressionCompiler = DummyEngine()
+        self.expressionCompiler = expressionCompiler
+        self.CompilerError = expressionCompiler.getCompilerError()
+        # This holds the emitted opcodes representing the input
+        self.program = []
+        # The program stack for when we need to do some sub-evaluation for an
+        # intermediate result.  E.g. in an i18n:name tag for which the
+        # contents describe the ${name} value.
+        self.stack = []
+        # Another stack of postponed actions.  Elements on this stack are a
+        # dictionary; key/values contain useful information that
+        # emitEndElement needs to finish its calculations
+        self.todoStack = []
+        self.macros = {}
+        self.slots = {}
+        self.slotStack = []
+        self.xml = xml
+        self.emit("version", TAL_VERSION)
+        self.emit("mode", xml and "xml" or "html")
+        if source_file is not None:
+            self.source_file = source_file
+            self.emit("setSourceFile", source_file)
+        self.i18nContext = TranslationContext()
+        self.i18nLevel = 0
+
+    def getCode(self):
+        assert not self.stack
+        assert not self.todoStack
+        return self.optimize(self.program), self.macros
+
+    def optimize(self, program):
+        output = []
+        collect = []
+        cursor = 0
+        if self.xml:
+            endsep = "/>"
+        else:
+            endsep = " />"
+        for cursor in xrange(len(program)+1):
+            try:
+                item = program[cursor]
+            except IndexError:
+                item = (None, None)
+            opcode = item[0]
+            if opcode == "rawtext":
+                collect.append(item[1])
+                continue
+            if opcode == "endTag":
+                collect.append("</%s>" % item[1])
+                continue
+            if opcode == "startTag":
+                if self.optimizeStartTag(collect, item[1], item[2], ">"):
+                    continue
+            if opcode == "startEndTag":
+                if self.optimizeStartTag(collect, item[1], item[2], endsep):
+                    continue
+            if opcode in ("beginScope", "endScope"):
+                # Push *Scope instructions in front of any text instructions;
+                # this allows text instructions separated only by *Scope
+                # instructions to be joined together.
+                output.append(self.optimizeArgsList(item))
+                continue
+            if opcode == 'noop':
+                # This is a spacer for end tags in the face of i18n:name
+                # attributes.  We can't let the optimizer collect immediately
+                # following end tags into the same rawtextOffset.
+                opcode = None
+                pass
+            text = "".join(collect)
+            if text:
+                i = text.rfind("\n")
+                if i >= 0:
+                    i = len(text) - (i + 1)
+                    output.append(("rawtextColumn", (text, i)))
+                else:
+                    output.append(("rawtextOffset", (text, len(text))))
+            if opcode != None:
+                output.append(self.optimizeArgsList(item))
+            collect = []
+        return self.optimizeCommonTriple(output)
+
+    def optimizeArgsList(self, item):
+        if len(item) == 2:
+            return item
+        else:
+            return item[0], tuple(item[1:])
+
+    # These codes are used to indicate what sort of special actions
+    # are needed for each special attribute.  (Simple attributes don't
+    # get action codes.)
+    #
+    # The special actions (which are modal) are handled by
+    # TALInterpreter.attrAction() and .attrAction_tal().
+    #
+    # Each attribute is represented by a tuple:
+    #
+    # (name, value)                 -- a simple name/value pair, with
+    #                                  no special processing
+    #
+    # (name, value, action, *extra) -- attribute with special
+    #                                  processing needs, action is a
+    #                                  code that indicates which
+    #                                  branch to take, and *extra
+    #                                  contains additional,
+    #                                  action-specific information
+    #                                  needed by the processing
+    #
+    def optimizeStartTag(self, collect, name, attrlist, end):
+        # return true if the tag can be converted to plain text
+        if not attrlist:
+            collect.append("<%s%s" % (name, end))
+            return 1
+        opt = 1
+        new = ["<" + name]
+        for i in range(len(attrlist)):
+            item = attrlist[i]
+            if len(item) > 2:
+                opt = 0
+                name, value, action = item[:3]
+                attrlist[i] = (name, value, action) + item[3:]
+            else:
+                if item[1] is None:
+                    s = item[0]
+                else:
+                    s = '%s="%s"' % (item[0], TALDefs.attrEscape(item[1]))
+                attrlist[i] = item[0], s
+                new.append(" " + s)
+        # if no non-optimizable attributes were found, convert to plain text
+        if opt:
+            new.append(end)
+            collect.extend(new)
+        return opt
+
+    def optimizeCommonTriple(self, program):
+        if len(program) < 3:
+            return program
+        output = program[:2]
+        prev2, prev1 = output
+        for item in program[2:]:
+            if ( item[0] == "beginScope"
+                 and prev1[0] == "setPosition"
+                 and prev2[0] == "rawtextColumn"):
+                position = output.pop()[1]
+                text, column = output.pop()[1]
+                prev1 = None, None
+                closeprev = 0
+                if output and output[-1][0] == "endScope":
+                    closeprev = 1
+                    output.pop()
+                item = ("rawtextBeginScope",
+                        (text, column, position, closeprev, item[1]))
+            output.append(item)
+            prev2 = prev1
+            prev1 = item
+        return output
+
+    def todoPush(self, todo):
+        self.todoStack.append(todo)
+
+    def todoPop(self):
+        return self.todoStack.pop()
+
+    def compileExpression(self, expr):
+        try:
+            return self.expressionCompiler.compile(expr)
+        except self.CompilerError, err:
+            raise TALError('%s in expression %s' % (err.args[0], `expr`),
+                           self.position)
+
+    def pushProgram(self):
+        self.stack.append(self.program)
+        self.program = []
+
+    def popProgram(self):
+        program = self.program
+        self.program = self.stack.pop()
+        return self.optimize(program)
+
+    def pushSlots(self):
+        self.slotStack.append(self.slots)
+        self.slots = {}
+
+    def popSlots(self):
+        slots = self.slots
+        self.slots = self.slotStack.pop()
+        return slots
+
+    def emit(self, *instruction):
+        self.program.append(instruction)
+
+    def emitStartTag(self, name, attrlist, isend=0):
+        if isend:
+            opcode = "startEndTag"
+        else:
+            opcode = "startTag"
+        self.emit(opcode, name, attrlist)
+
+    def emitEndTag(self, name):
+        if self.xml and self.program and self.program[-1][0] == "startTag":
+            # Minimize empty element
+            self.program[-1] = ("startEndTag",) + self.program[-1][1:]
+        else:
+            self.emit("endTag", name)
+
+    def emitOptTag(self, name, optTag, isend):
+        program = self.popProgram() #block
+        start = self.popProgram() #start tag
+        if (isend or not program) and self.xml:
+            # Minimize empty element
+            start[-1] = ("startEndTag",) + start[-1][1:]
+            isend = 1
+        cexpr = optTag[0]
+        if cexpr:
+            cexpr = self.compileExpression(optTag[0])
+        self.emit("optTag", name, cexpr, optTag[1], isend, start, program)
+
+    def emitRawText(self, text):
+        self.emit("rawtext", text)
+
+    def emitText(self, text):
+        self.emitRawText(cgi.escape(text))
+
+    def emitDefines(self, defines):
+        for part in TALDefs.splitParts(defines):
+            m = re.match(
+                r"(?s)\s*(?:(global|local)\s+)?(%s)\s+(.*)\Z" % NAME_RE, part)
+            if not m:
+                raise TALError("invalid define syntax: " + `part`,
+                               self.position)
+            scope, name, expr = m.group(1, 2, 3)
+            scope = scope or "local"
+            cexpr = self.compileExpression(expr)
+            if scope == "local":
+                self.emit("setLocal", name, cexpr)
+            else:
+                self.emit("setGlobal", name, cexpr)
+
+    def emitOnError(self, name, onError, TALtag, isend):
+        block = self.popProgram()
+        key, expr = parseSubstitution(onError)
+        cexpr = self.compileExpression(expr)
+        if key == "text":
+            self.emit("insertText", cexpr, [])
+        else:
+            assert key == "structure"
+            self.emit("insertStructure", cexpr, {}, [])
+        if TALtag:
+            self.emitOptTag(name, (None, 1), isend)
+        else:
+            self.emitEndTag(name)
+        handler = self.popProgram()
+        self.emit("onError", block, handler)
+
+    def emitCondition(self, expr):
+        cexpr = self.compileExpression(expr)
+        program = self.popProgram()
+        self.emit("condition", cexpr, program)
+
+    def emitRepeat(self, arg):
+        m = re.match("(?s)\s*(%s)\s+(.*)\Z" % NAME_RE, arg)
+        if not m:
+            raise TALError("invalid repeat syntax: " + `arg`,
+                           self.position)
+        name, expr = m.group(1, 2)
+        cexpr = self.compileExpression(expr)
+        program = self.popProgram()
+        self.emit("loop", name, cexpr, program)
+
+    def emitSubstitution(self, arg, attrDict={}):
+        key, expr = parseSubstitution(arg)
+        cexpr = self.compileExpression(expr)
+        program = self.popProgram()
+        if key == "text":
+            self.emit("insertText", cexpr, program)
+        else:
+            assert key == "structure"
+            self.emit("insertStructure", cexpr, attrDict, program)
+
+    def emitI18nVariable(self, stuff):
+        # Used for i18n:name attributes.  arg is extra information describing
+        # how the contents of the variable should get filled in, and it will
+        # either be a 1-tuple or a 2-tuple.  If arg[0] is None, then the
+        # i18n:name value is taken implicitly from the contents of the tag,
+        # e.g. "I live in <span i18n:name="country">the USA</span>".  In this
+        # case, arg[1] is the opcode sub-program describing the contents of
+        # the tag.
+        #
+        # When arg[0] is not None, it contains the tal expression used to
+        # calculate the contents of the variable, e.g.
+        # "I live in <span i18n:name="country"
+        #                  tal:replace="here/countryOfOrigin" />"
+        varname, action, expression = stuff
+        m = _name_rx.match(varname)
+        if m is None or m.group() != varname:
+            raise TALError("illegal i18n:name: %r" % varname, self.position)
+        key = cexpr = None
+        program = self.popProgram()
+        if action == I18N_REPLACE:
+            # This is a tag with an i18n:name and a tal:replace (implicit or
+            # explicit).  Get rid of the first and last elements of the
+            # program, which are the start and end tag opcodes of the tag.
+            program = program[1:-1]
+        elif action == I18N_CONTENT:
+            # This is a tag with an i18n:name and a tal:content
+            # (explicit-only).  Keep the first and last elements of the
+            # program, so we keep the start and end tag output.
+            pass
+        else:
+            assert action == I18N_EXPRESSION
+            key, expr = parseSubstitution(expression)
+            cexpr = self.compileExpression(expr)
+        # XXX Would key be anything but 'text' or None?
+        assert key in ('text', None)
+        self.emit('i18nVariable', varname, program, cexpr)
+
+    def emitTranslation(self, msgid, i18ndata):
+        program = self.popProgram()
+        if i18ndata is None:
+            self.emit('insertTranslation', msgid, program)
+        else:
+            key, expr = parseSubstitution(i18ndata)
+            cexpr = self.compileExpression(expr)
+            assert key == 'text'
+            self.emit('insertTranslation', msgid, program, cexpr)
+
+    def emitDefineMacro(self, macroName):
+        program = self.popProgram()
+        macroName = macroName.strip()
+        if self.macros.has_key(macroName):
+            raise METALError("duplicate macro definition: %s" % `macroName`,
+                             self.position)
+        if not re.match('%s$' % NAME_RE, macroName):
+            raise METALError("invalid macro name: %s" % `macroName`,
+                             self.position)
+        self.macros[macroName] = program
+        self.inMacroDef = self.inMacroDef - 1
+        self.emit("defineMacro", macroName, program)
+
+    def emitUseMacro(self, expr):
+        cexpr = self.compileExpression(expr)
+        program = self.popProgram()
+        self.inMacroUse = 0
+        self.emit("useMacro", expr, cexpr, self.popSlots(), program)
+
+    def emitDefineSlot(self, slotName):
+        program = self.popProgram()
+        slotName = slotName.strip()
+        if not re.match('%s$' % NAME_RE, slotName):
+            raise METALError("invalid slot name: %s" % `slotName`,
+                             self.position)
+        self.emit("defineSlot", slotName, program)
+
+    def emitFillSlot(self, slotName):
+        program = self.popProgram()
+        slotName = slotName.strip()
+        if self.slots.has_key(slotName):
+            raise METALError("duplicate fill-slot name: %s" % `slotName`,
+                             self.position)
+        if not re.match('%s$' % NAME_RE, slotName):
+            raise METALError("invalid slot name: %s" % `slotName`,
+                             self.position)
+        self.slots[slotName] = program
+        self.inMacroUse = 1
+        self.emit("fillSlot", slotName, program)
+
+    def unEmitWhitespace(self):
+        collect = []
+        i = len(self.program) - 1
+        while i >= 0:
+            item = self.program[i]
+            if item[0] != "rawtext":
+                break
+            text = item[1]
+            if not re.match(r"\A\s*\Z", text):
+                break
+            collect.append(text)
+            i = i-1
+        del self.program[i+1:]
+        if i >= 0 and self.program[i][0] == "rawtext":
+            text = self.program[i][1]
+            m = re.search(r"\s+\Z", text)
+            if m:
+                self.program[i] = ("rawtext", text[:m.start()])
+                collect.append(m.group())
+        collect.reverse()
+        return "".join(collect)
+
+    def unEmitNewlineWhitespace(self):
+        collect = []
+        i = len(self.program)
+        while i > 0:
+            i = i-1
+            item = self.program[i]
+            if item[0] != "rawtext":
+                break
+            text = item[1]
+            if re.match(r"\A[ \t]*\Z", text):
+                collect.append(text)
+                continue
+            m = re.match(r"(?s)^(.*)(\n[ \t]*)\Z", text)
+            if not m:
+                break
+            text, rest = m.group(1, 2)
+            collect.reverse()
+            rest = rest + "".join(collect)
+            del self.program[i:]
+            if text:
+                self.emit("rawtext", text)
+            return rest
+        return None
+
+    def replaceAttrs(self, attrlist, repldict):
+        # Each entry in attrlist starts like (name, value).
+        # Result is (name, value, action, expr, xlat) if there is a
+        # tal:attributes entry for that attribute.  Additional attrs
+        # defined only by tal:attributes are added here.
+        #
+        # (name, value, action, expr, xlat)
+        if not repldict:
+            return attrlist
+        newlist = []
+        for item in attrlist:
+            key = item[0]
+            if repldict.has_key(key):
+                expr, xlat, msgid = repldict[key]
+                item = item[:2] + ("replace", expr, xlat, msgid)
+                del repldict[key]
+            newlist.append(item)
+        # Add dynamic-only attributes
+        for key, (expr, xlat, msgid) in repldict.items():
+            newlist.append((key, None, "insert", expr, xlat, msgid))
+        return newlist
+
+    def emitStartElement(self, name, attrlist, taldict, metaldict, i18ndict,
+                         position=(None, None), isend=0):
+        if not taldict and not metaldict and not i18ndict:
+            # Handle the simple, common case
+            self.emitStartTag(name, attrlist, isend)
+            self.todoPush({})
+            if isend:
+                self.emitEndElement(name, isend)
+            return
+
+        self.position = position
+        for key, value in taldict.items():
+            if key not in TALDefs.KNOWN_TAL_ATTRIBUTES:
+                raise TALError("bad TAL attribute: " + `key`, position)
+            if not (value or key == 'omit-tag'):
+                raise TALError("missing value for TAL attribute: " +
+                               `key`, position)
+        for key, value in metaldict.items():
+            if key not in TALDefs.KNOWN_METAL_ATTRIBUTES:
+                raise METALError("bad METAL attribute: " + `key`,
+                                 position)
+            if not value:
+                raise TALError("missing value for METAL attribute: " +
+                               `key`, position)
+        for key, value in i18ndict.items():
+            if key not in TALDefs.KNOWN_I18N_ATTRIBUTES:
+                raise I18NError("bad i18n attribute: " + `key`, position)
+            if not value and key in ("attributes", "data", "id"):
+                raise I18NError("missing value for i18n attribute: " +
+                                `key`, position)
+        todo = {}
+        defineMacro = metaldict.get("define-macro")
+        useMacro = metaldict.get("use-macro")
+        defineSlot = metaldict.get("define-slot")
+        fillSlot = metaldict.get("fill-slot")
+        define = taldict.get("define")
+        condition = taldict.get("condition")
+        repeat = taldict.get("repeat")
+        content = taldict.get("content")
+        replace = taldict.get("replace")
+        attrsubst = taldict.get("attributes")
+        onError = taldict.get("on-error")
+        omitTag = taldict.get("omit-tag")
+        TALtag = taldict.get("tal tag")
+        i18nattrs = i18ndict.get("attributes")
+        # Preserve empty string if implicit msgids are used.  We'll generate
+        # code with the msgid='' and calculate the right implicit msgid during
+        # interpretation phase.
+        msgid = i18ndict.get("translate")
+        varname = i18ndict.get('name')
+        i18ndata = i18ndict.get('data')
+
+        if varname and not self.i18nLevel:
+            raise I18NError(
+                "i18n:name can only occur inside a translation unit",
+                position)
+
+        if i18ndata and not msgid:
+            raise I18NError("i18n:data must be accompanied by i18n:translate",
+                            position)
+
+        if len(metaldict) > 1 and (defineMacro or useMacro):
+            raise METALError("define-macro and use-macro cannot be used "
+                             "together or with define-slot or fill-slot",
+                             position)
+        if replace:
+            if content:
+                raise TALError(
+                    "tal:content and tal:replace are mutually exclusive",
+                    position)
+            if msgid is not None:
+                raise I18NError(
+                    "i18n:translate and tal:replace are mutually exclusive",
+                    position)
+
+        repeatWhitespace = None
+        if repeat:
+            # Hack to include preceding whitespace in the loop program
+            repeatWhitespace = self.unEmitNewlineWhitespace()
+        if position != (None, None):
+            # XXX at some point we should insist on a non-trivial position
+            self.emit("setPosition", position)
+        if self.inMacroUse:
+            if fillSlot:
+                self.pushProgram()
+                if self.source_file is not None:
+                    self.emit("setSourceFile", self.source_file)
+                todo["fillSlot"] = fillSlot
+                self.inMacroUse = 0
+        else:
+            if fillSlot:
+                raise METALError("fill-slot must be within a use-macro",
+                                 position)
+        if not self.inMacroUse:
+            if defineMacro:
+                self.pushProgram()
+                self.emit("version", TAL_VERSION)
+                self.emit("mode", self.xml and "xml" or "html")
+                if self.source_file is not None:
+                    self.emit("setSourceFile", self.source_file)
+                todo["defineMacro"] = defineMacro
+                self.inMacroDef = self.inMacroDef + 1
+            if useMacro:
+                self.pushSlots()
+                self.pushProgram()
+                todo["useMacro"] = useMacro
+                self.inMacroUse = 1
+            if defineSlot:
+                if not self.inMacroDef:
+                    raise METALError(
+                        "define-slot must be within a define-macro",
+                        position)
+                self.pushProgram()
+                todo["defineSlot"] = defineSlot
+
+        if defineSlot or i18ndict:
+
+            domain = i18ndict.get("domain") or self.i18nContext.domain
+            source = i18ndict.get("source") or self.i18nContext.source
+            target = i18ndict.get("target") or self.i18nContext.target
+            if (  domain != DEFAULT_DOMAIN
+                  or source is not None
+                  or target is not None):
+                self.i18nContext = TranslationContext(self.i18nContext,
+                                                      domain=domain,
+                                                      source=source,
+                                                      target=target)
+                self.emit("beginI18nContext",
+                          {"domain": domain, "source": source,
+                           "target": target})
+                todo["i18ncontext"] = 1
+        if taldict or i18ndict:
+            dict = {}
+            for item in attrlist:
+                key, value = item[:2]
+                dict[key] = value
+            self.emit("beginScope", dict)
+            todo["scope"] = 1
+        if onError:
+            self.pushProgram() # handler
+            if TALtag:
+                self.pushProgram() # start
+            self.emitStartTag(name, list(attrlist)) # Must copy attrlist!
+            if TALtag:
+                self.pushProgram() # start
+            self.pushProgram() # block
+            todo["onError"] = onError
+        if define:
+            self.emitDefines(define)
+            todo["define"] = define
+        if condition:
+            self.pushProgram()
+            todo["condition"] = condition
+        if repeat:
+            todo["repeat"] = repeat
+            self.pushProgram()
+            if repeatWhitespace:
+                self.emitText(repeatWhitespace)
+        if content:
+            if varname:
+                todo['i18nvar'] = (varname, I18N_CONTENT, None)
+                todo["content"] = content
+                self.pushProgram()
+            else:
+                todo["content"] = content
+        elif replace:
+            # tal:replace w/ i18n:name has slightly different semantics.  What
+            # we're actually replacing then is the contents of the ${name}
+            # placeholder.
+            if varname:
+                todo['i18nvar'] = (varname, I18N_EXPRESSION, replace)
+            else:
+                todo["replace"] = replace
+            self.pushProgram()
+        # i18n:name w/o tal:replace uses the content as the interpolation
+        # dictionary values
+        elif varname:
+            todo['i18nvar'] = (varname, I18N_REPLACE, None)
+            self.pushProgram()
+        if msgid is not None:
+            self.i18nLevel += 1
+            todo['msgid'] = msgid
+        if i18ndata:
+            todo['i18ndata'] = i18ndata
+        optTag = omitTag is not None or TALtag
+        if optTag:
+            todo["optional tag"] = omitTag, TALtag
+            self.pushProgram()
+        if attrsubst or i18nattrs:
+            if attrsubst:
+                repldict = TALDefs.parseAttributeReplacements(attrsubst,
+                                                              self.xml)
+            else:
+                repldict = {}
+            if i18nattrs:
+                i18nattrs = _parseI18nAttributes(i18nattrs, attrlist, repldict,
+                                                 self.position, self.xml,
+                                                 self.source_file)
+            else:
+                i18nattrs = {}
+            # Convert repldict's name-->expr mapping to a
+            # name-->(compiled_expr, translate) mapping
+            for key, value in repldict.items():
+                if i18nattrs.get(key, None):
+                    raise I18NError(
+                      ("attribute [%s] cannot both be part of tal:attributes" +
+                      " and have a msgid in i18n:attributes") % key,
+                    position)
+                ce = self.compileExpression(value)
+                repldict[key] = ce, key in i18nattrs, i18nattrs.get(key)
+            for key in i18nattrs:
+                if not repldict.has_key(key):
+                    repldict[key] = None, 1, i18nattrs.get(key)
+        else:
+            repldict = {}
+        if replace:
+            todo["repldict"] = repldict
+            repldict = {}
+        self.emitStartTag(name, self.replaceAttrs(attrlist, repldict), isend)
+        if optTag:
+            self.pushProgram()
+        if content and not varname:
+            self.pushProgram()
+        if msgid is not None:
+            self.pushProgram()
+        if content and varname:
+            self.pushProgram()
+        if todo and position != (None, None):
+            todo["position"] = position
+        self.todoPush(todo)
+        if isend:
+            self.emitEndElement(name, isend)
+
+    def emitEndElement(self, name, isend=0, implied=0):
+        todo = self.todoPop()
+        if not todo:
+            # Shortcut
+            if not isend:
+                self.emitEndTag(name)
+            return
+
+        self.position = position = todo.get("position", (None, None))
+        defineMacro = todo.get("defineMacro")
+        useMacro = todo.get("useMacro")
+        defineSlot = todo.get("defineSlot")
+        fillSlot = todo.get("fillSlot")
+        repeat = todo.get("repeat")
+        content = todo.get("content")
+        replace = todo.get("replace")
+        condition = todo.get("condition")
+        onError = todo.get("onError")
+        define = todo.get("define")
+        repldict = todo.get("repldict", {})
+        scope = todo.get("scope")
+        optTag = todo.get("optional tag")
+        msgid = todo.get('msgid')
+        i18ncontext = todo.get("i18ncontext")
+        varname = todo.get('i18nvar')
+        i18ndata = todo.get('i18ndata')
+
+        if implied > 0:
+            if defineMacro or useMacro or defineSlot or fillSlot:
+                exc = METALError
+                what = "METAL"
+            else:
+                exc = TALError
+                what = "TAL"
+            raise exc("%s attributes on <%s> require explicit </%s>" %
+                      (what, name, name), position)
+
+        # If there's no tal:content or tal:replace in the tag with the
+        # i18n:name, tal:replace is the default.
+        if content:
+            self.emitSubstitution(content, {})
+        # If we're looking at an implicit msgid, emit the insertTranslation
+        # opcode now, so that the end tag doesn't become part of the implicit
+        # msgid.  If we're looking at an explicit msgid, it's better to emit
+        # the opcode after the i18nVariable opcode so we can better handle
+        # tags with both of them in them (and in the latter case, the contents
+        # would be thrown away for msgid purposes).
+        #
+        # Still, we should emit insertTranslation opcode before i18nVariable
+        # in case tal:content, i18n:translate and i18n:name in the same tag
+        if msgid is not None:
+            if (not varname) or (
+                varname and (varname[1] == I18N_CONTENT)):
+                self.emitTranslation(msgid, i18ndata)
+            self.i18nLevel -= 1
+        if optTag:
+            self.emitOptTag(name, optTag, isend)
+        elif not isend:
+            # If we're processing the end tag for a tag that contained
+            # i18n:name, we need to make sure that optimize() won't collect
+            # immediately following end tags into the same rawtextOffset, so
+            # put a spacer here that the optimizer will recognize.
+            if varname:
+                self.emit('noop')
+            self.emitEndTag(name)
+        # If i18n:name appeared in the same tag as tal:replace then we're
+        # going to do the substitution a little bit differently.  The results
+        # of the expression go into the i18n substitution dictionary.
+        if replace:
+            self.emitSubstitution(replace, repldict)
+        elif varname:
+            # o varname[0] is the variable name
+            # o varname[1] is either
+            #   - I18N_REPLACE for implicit tal:replace
+            #   - I18N_CONTENT for tal:content
+            #   - I18N_EXPRESSION for explicit tal:replace
+            # o varname[2] will be None for the first two actions and the
+            #   replacement tal expression for the third action.
+            assert (varname[1]
+                    in [I18N_REPLACE, I18N_CONTENT, I18N_EXPRESSION])
+            self.emitI18nVariable(varname)
+        # Do not test for "msgid is not None", i.e. we only want to test for
+        # explicit msgids here.  See comment above.
+        if msgid is not None:
+            # in case tal:content, i18n:translate and i18n:name in the
+            # same tag insertTranslation opcode has already been
+            # emitted
+            if varname and (varname[1] <> I18N_CONTENT):
+                self.emitTranslation(msgid, i18ndata)
+        if repeat:
+            self.emitRepeat(repeat)
+        if condition:
+            self.emitCondition(condition)
+        if onError:
+            self.emitOnError(name, onError, optTag and optTag[1], isend)
+        if scope:
+            self.emit("endScope")
+        if i18ncontext:
+            self.emit("endI18nContext")
+            assert self.i18nContext.parent is not None
+            self.i18nContext = self.i18nContext.parent
+        if defineSlot:
+            self.emitDefineSlot(defineSlot)
+        if fillSlot:
+            self.emitFillSlot(fillSlot)
+        if useMacro:
+            self.emitUseMacro(useMacro)
+        if defineMacro:
+            self.emitDefineMacro(defineMacro)
+
+
+def _parseI18nAttributes(i18nattrs, attrlist, repldict, position,
+                         xml, source_file):
+
+    def addAttribute(dic, attr, msgid, position, xml):
+        if not xml:
+            attr = attr.lower()
+        if attr in dic:
+            raise TALError(
+                "attribute may only be specified once in i18n:attributes: "
+                + attr,
+                position)
+        dic[attr] = msgid
+
+    d = {}
+    if ';' in i18nattrs:
+        i18nattrlist = i18nattrs.split(';')
+        i18nattrlist = [attr.strip().split()
+                        for attr in i18nattrlist if attr.strip()]
+        for parts in i18nattrlist:
+            if len(parts) > 2:
+                raise TALError("illegal i18n:attributes specification: %r"
+                                % parts, position)
+            if len(parts) == 2:
+                attr, msgid = parts
+            else:
+                # len(parts) == 1
+                attr = parts[0]
+                msgid = None
+            addAttribute(d, attr, msgid, position, xml)
+    else:
+        i18nattrlist = i18nattrs.split()
+        if len(i18nattrlist) == 2:
+            staticattrs = [attr[0] for attr in attrlist if len(attr) == 2]
+            if (not i18nattrlist[1] in staticattrs) and (
+                not i18nattrlist[1] in repldict):
+                attr, msgid = i18nattrlist
+                addAttribute(d, attr, msgid, position, xml)
+            else:
+                msgid = None
+                for attr in i18nattrlist:
+                    addAttribute(d, attr, msgid, position, xml)
+        else:
+            msgid = None
+            for attr in i18nattrlist:
+                addAttribute(d, attr, msgid, position, xml)
+    return d
+
+def test():
+    t = TALGenerator()
+    t.pushProgram()
+    t.emit("bar")
+    p = t.popProgram()
+    t.emit("foo", p)
+
+if __name__ == "__main__":
+    test()

Added: tracker/vendor/roundup/current/roundup/cgi/TAL/TALInterpreter.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/TAL/TALInterpreter.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,758 @@
+##############################################################################
+#
+# Copyright (c) 2001, 2002 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.0 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+# Modifications for Roundup:
+# 1. implemented ustr as str
+"""
+Interpreter for a pre-compiled TAL program.
+"""
+
+import sys
+import getopt
+import re
+from types import ListType
+from cgi import escape
+# Do not use cStringIO here!  It's not unicode aware. :(
+from StringIO import StringIO
+#from DocumentTemplate.DT_Util import ustr
+ustr = str
+
+from TALDefs import TAL_VERSION, TALError, METALError, attrEscape
+from TALDefs import isCurrentVersion, getProgramVersion, getProgramMode
+from TALGenerator import TALGenerator
+from TranslationContext import TranslationContext
+
+BOOLEAN_HTML_ATTRS = [
+    # List of Boolean attributes in HTML that should be rendered in
+    # minimized form (e.g. <img ismap> rather than <img ismap="">)
+    # From http://www.w3.org/TR/xhtml1/#guidelines (C.10)
+    # XXX The problem with this is that this is not valid XML and
+    # can't be parsed back!
+    "compact", "nowrap", "ismap", "declare", "noshade", "checked",
+    "disabled", "readonly", "multiple", "selected", "noresize",
+    "defer"
+]
+
+def normalize(text):
+    # Now we need to normalize the whitespace in implicit message ids and
+    # implicit $name substitution values by stripping leading and trailing
+    # whitespace, and folding all internal whitespace to a single space.
+    return ' '.join(text.split())
+
+
+NAME_RE = r"[a-zA-Z][a-zA-Z0-9_]*"
+_interp_regex = re.compile(r'(?<!\$)(\$(?:%(n)s|{%(n)s}))' %({'n': NAME_RE}))
+_get_var_regex = re.compile(r'%(n)s' %({'n': NAME_RE}))
+
+def interpolate(text, mapping):
+    """Interpolate ${keyword} substitutions.
+
+    This is called when no translation is provided by the translation
+    service.
+    """
+    if not mapping:
+        return text
+    # Find all the spots we want to substitute.
+    to_replace = _interp_regex.findall(text)
+    # Now substitute with the variables in mapping.
+    for string in to_replace:
+        var = _get_var_regex.findall(string)[0]
+        if mapping.has_key(var):
+            # Call ustr because we may have an integer for instance.
+            subst = ustr(mapping[var])
+            try:
+                text = text.replace(string, subst)
+            except UnicodeError:
+                # subst contains high-bit chars...
+                # As we have no way of knowing the correct encoding,
+                # substitue something instead of raising an exception.
+                subst = `subst`[1:-1]
+                text = text.replace(string, subst)
+    return text
+
+
+class AltTALGenerator(TALGenerator):
+
+    def __init__(self, repldict, expressionCompiler=None, xml=0):
+        self.repldict = repldict
+        self.enabled = 1
+        TALGenerator.__init__(self, expressionCompiler, xml)
+
+    def enable(self, enabled):
+        self.enabled = enabled
+
+    def emit(self, *args):
+        if self.enabled:
+            TALGenerator.emit(self, *args)
+
+    def emitStartElement(self, name, attrlist, taldict, metaldict, i18ndict,
+                         position=(None, None), isend=0):
+        metaldict = {}
+        taldict = {}
+        i18ndict = {}
+        if self.enabled and self.repldict:
+            taldict["attributes"] = "x x"
+        TALGenerator.emitStartElement(self, name, attrlist,
+                                      taldict, metaldict, i18ndict,
+                                      position, isend)
+
+    def replaceAttrs(self, attrlist, repldict):
+        if self.enabled and self.repldict:
+            repldict = self.repldict
+            self.repldict = None
+        return TALGenerator.replaceAttrs(self, attrlist, repldict)
+
+
+class TALInterpreter:
+
+    def __init__(self, program, macros, engine, stream=None,
+                 debug=0, wrap=60, metal=1, tal=1, showtal=-1,
+                 strictinsert=1, stackLimit=100, i18nInterpolate=1):
+        self.program = program
+        self.macros = macros
+        self.engine = engine # Execution engine (aka context)
+        self.Default = engine.getDefault()
+        self.stream = stream or sys.stdout
+        self._stream_write = self.stream.write
+        self.debug = debug
+        self.wrap = wrap
+        self.metal = metal
+        self.tal = tal
+        if tal:
+            self.dispatch = self.bytecode_handlers_tal
+        else:
+            self.dispatch = self.bytecode_handlers
+        assert showtal in (-1, 0, 1)
+        if showtal == -1:
+            showtal = (not tal)
+        self.showtal = showtal
+        self.strictinsert = strictinsert
+        self.stackLimit = stackLimit
+        self.html = 0
+        self.endsep = "/>"
+        self.endlen = len(self.endsep)
+        self.macroStack = []
+        self.position = None, None  # (lineno, offset)
+        self.col = 0
+        self.level = 0
+        self.scopeLevel = 0
+        self.sourceFile = None
+        self.i18nStack = []
+        self.i18nInterpolate = i18nInterpolate
+        self.i18nContext = TranslationContext()
+
+    def StringIO(self):
+        # Third-party products wishing to provide a full Unicode-aware
+        # StringIO can do so by monkey-patching this method.
+        return FasterStringIO()
+
+    def saveState(self):
+        return (self.position, self.col, self.stream,
+                self.scopeLevel, self.level, self.i18nContext)
+
+    def restoreState(self, state):
+        (self.position, self.col, self.stream, scopeLevel, level, i18n) = state
+        self._stream_write = self.stream.write
+        assert self.level == level
+        while self.scopeLevel > scopeLevel:
+            self.engine.endScope()
+            self.scopeLevel = self.scopeLevel - 1
+        self.engine.setPosition(self.position)
+        self.i18nContext = i18n
+
+    def restoreOutputState(self, state):
+        (dummy, self.col, self.stream, scopeLevel, level, i18n) = state
+        self._stream_write = self.stream.write
+        assert self.level == level
+        assert self.scopeLevel == scopeLevel
+
+    def pushMacro(self, macroName, slots, entering=1):
+        if len(self.macroStack) >= self.stackLimit:
+            raise METALError("macro nesting limit (%d) exceeded "
+                             "by %s" % (self.stackLimit, `macroName`))
+        self.macroStack.append([macroName, slots, entering, self.i18nContext])
+
+    def popMacro(self):
+        return self.macroStack.pop()
+
+    def __call__(self):
+        assert self.level == 0
+        assert self.scopeLevel == 0
+        assert self.i18nContext.parent is None
+        self.interpret(self.program)
+        assert self.level == 0
+        assert self.scopeLevel == 0
+        assert self.i18nContext.parent is None
+        if self.col > 0:
+            self._stream_write("\n")
+            self.col = 0
+
+    def stream_write(self, s,
+                     len=len):
+        self._stream_write(s)
+        i = s.rfind('\n')
+        if i < 0:
+            self.col = self.col + len(s)
+        else:
+            self.col = len(s) - (i + 1)
+
+    bytecode_handlers = {}
+
+    def interpretWithStream(self, program, stream):
+        oldstream = self.stream
+        self.stream = stream
+        self._stream_write = stream.write
+        try:
+            self.interpret(program)
+        finally:
+            self.stream = oldstream
+            self._stream_write = oldstream.write
+
+    def interpret(self, program):
+        oldlevel = self.level
+        self.level = oldlevel + 1
+        handlers = self.dispatch
+        try:
+            if self.debug:
+                for (opcode, args) in program:
+                    s = "%sdo_%s(%s)\n" % ("    "*self.level, opcode,
+                                           repr(args))
+                    if len(s) > 80:
+                        s = s[:76] + "...\n"
+                    sys.stderr.write(s)
+                    handlers[opcode](self, args)
+            else:
+                for (opcode, args) in program:
+                    handlers[opcode](self, args)
+        finally:
+            self.level = oldlevel
+
+    def do_version(self, version):
+        assert version == TAL_VERSION
+    bytecode_handlers["version"] = do_version
+
+    def do_mode(self, mode):
+        assert mode in ("html", "xml")
+        self.html = (mode == "html")
+        if self.html:
+            self.endsep = " />"
+        else:
+            self.endsep = "/>"
+        self.endlen = len(self.endsep)
+    bytecode_handlers["mode"] = do_mode
+
+    def do_setSourceFile(self, source_file):
+        self.sourceFile = source_file
+        self.engine.setSourceFile(source_file)
+    bytecode_handlers["setSourceFile"] = do_setSourceFile
+
+    def do_setPosition(self, position):
+        self.position = position
+        self.engine.setPosition(position)
+    bytecode_handlers["setPosition"] = do_setPosition
+
+    def do_startEndTag(self, stuff):
+        self.do_startTag(stuff, self.endsep, self.endlen)
+    bytecode_handlers["startEndTag"] = do_startEndTag
+
+    def do_startTag(self, (name, attrList),
+                    end=">", endlen=1, _len=len):
+        # The bytecode generator does not cause calls to this method
+        # for start tags with no attributes; those are optimized down
+        # to rawtext events.  Hence, there is no special "fast path"
+        # for that case.
+        L = ["<", name]
+        append = L.append
+        col = self.col + _len(name) + 1
+        wrap = self.wrap
+        align = col + 1
+        if align >= wrap/2:
+            align = 4  # Avoid a narrow column far to the right
+        attrAction = self.dispatch["<attrAction>"]
+        try:
+            for item in attrList:
+                if _len(item) == 2:
+                    name, s = item
+                else:
+                    # item[2] is the 'action' field:
+                    if item[2] in ('metal', 'tal', 'xmlns', 'i18n'):
+                        if not self.showtal:
+                            continue
+                        ok, name, s = self.attrAction(item)
+                    else:
+                        ok, name, s = attrAction(self, item)
+                    if not ok:
+                        continue
+                slen = _len(s)
+                if (wrap and
+                    col >= align and
+                    col + 1 + slen > wrap):
+                    append("\n")
+                    append(" "*align)
+                    col = align + slen
+                else:
+                    append(" ")
+                    col = col + 1 + slen
+                append(s)
+            append(end)
+            self._stream_write("".join(L))
+            col = col + endlen
+        finally:
+            self.col = col
+    bytecode_handlers["startTag"] = do_startTag
+
+    def attrAction(self, item):
+        name, value, action = item[:3]
+        if action == 'insert':
+            return 0, name, value
+        macs = self.macroStack
+        if action == 'metal' and self.metal and macs:
+            if len(macs) > 1 or not macs[-1][2]:
+                # Drop all METAL attributes at a use-depth above one.
+                return 0, name, value
+            # Clear 'entering' flag
+            macs[-1][2] = 0
+            # Convert or drop depth-one METAL attributes.
+            i = name.rfind(":") + 1
+            prefix, suffix = name[:i], name[i:]
+            if suffix == "define-macro":
+                # Convert define-macro as we enter depth one.
+                name = prefix + "use-macro"
+                value = macs[-1][0] # Macro name
+            elif suffix == "define-slot":
+                name = prefix + "fill-slot"
+            elif suffix == "fill-slot":
+                pass
+            else:
+                return 0, name, value
+
+        if value is None:
+            value = name
+        else:
+            value = '%s="%s"' % (name, attrEscape(value))
+        return 1, name, value
+
+    def attrAction_tal(self, item):
+        name, value, action = item[:3]
+        ok = 1
+        expr, xlat, msgid = item[3:]
+        if self.html and name.lower() in BOOLEAN_HTML_ATTRS:
+            evalue = self.engine.evaluateBoolean(item[3])
+            if evalue is self.Default:
+                if action == 'insert': # Cancelled insert
+                    ok = 0
+            elif evalue:
+                value = None
+            else:
+                ok = 0
+        elif expr is not None:
+            evalue = self.engine.evaluateText(item[3])
+            if evalue is self.Default:
+                if action == 'insert': # Cancelled insert
+                    ok = 0
+            else:
+                if evalue is None:
+                    ok = 0
+                value = evalue
+        else:
+            evalue = None
+
+        if ok:
+            if xlat:
+                translated = self.translate(msgid or value, value, {})
+                if translated is not None:
+                    value = translated
+            if value is None:
+                value = name
+            elif evalue is self.Default:
+                value = attrEscape(value)
+            else:
+                value = escape(value, quote=1)
+            value = '%s="%s"' % (name, value)
+        return ok, name, value
+    bytecode_handlers["<attrAction>"] = attrAction
+
+    def no_tag(self, start, program):
+        state = self.saveState()
+        self.stream = stream = self.StringIO()
+        self._stream_write = stream.write
+        self.interpret(start)
+        self.restoreOutputState(state)
+        self.interpret(program)
+
+    def do_optTag(self, (name, cexpr, tag_ns, isend, start, program),
+                  omit=0):
+        if tag_ns and not self.showtal:
+            return self.no_tag(start, program)
+
+        self.interpret(start)
+        if not isend:
+            self.interpret(program)
+            s = '</%s>' % name
+            self._stream_write(s)
+            self.col = self.col + len(s)
+
+    def do_optTag_tal(self, stuff):
+        cexpr = stuff[1]
+        if cexpr is not None and (cexpr == '' or
+                                  self.engine.evaluateBoolean(cexpr)):
+            self.no_tag(stuff[-2], stuff[-1])
+        else:
+            self.do_optTag(stuff)
+    bytecode_handlers["optTag"] = do_optTag
+
+    def do_rawtextBeginScope(self, (s, col, position, closeprev, dict)):
+        self._stream_write(s)
+        self.col = col
+        self.position = position
+        self.engine.setPosition(position)
+        if closeprev:
+            engine = self.engine
+            engine.endScope()
+            engine.beginScope()
+        else:
+            self.engine.beginScope()
+            self.scopeLevel = self.scopeLevel + 1
+
+    def do_rawtextBeginScope_tal(self, (s, col, position, closeprev, dict)):
+        self._stream_write(s)
+        self.col = col
+        engine = self.engine
+        self.position = position
+        engine.setPosition(position)
+        if closeprev:
+            engine.endScope()
+            engine.beginScope()
+        else:
+            engine.beginScope()
+            self.scopeLevel = self.scopeLevel + 1
+        engine.setLocal("attrs", dict)
+    bytecode_handlers["rawtextBeginScope"] = do_rawtextBeginScope
+
+    def do_beginScope(self, dict):
+        self.engine.beginScope()
+        self.scopeLevel = self.scopeLevel + 1
+
+    def do_beginScope_tal(self, dict):
+        engine = self.engine
+        engine.beginScope()
+        engine.setLocal("attrs", dict)
+        self.scopeLevel = self.scopeLevel + 1
+    bytecode_handlers["beginScope"] = do_beginScope
+
+    def do_endScope(self, notused=None):
+        self.engine.endScope()
+        self.scopeLevel = self.scopeLevel - 1
+    bytecode_handlers["endScope"] = do_endScope
+
+    def do_setLocal(self, notused):
+        pass
+
+    def do_setLocal_tal(self, (name, expr)):
+        self.engine.setLocal(name, self.engine.evaluateValue(expr))
+    bytecode_handlers["setLocal"] = do_setLocal
+
+    def do_setGlobal_tal(self, (name, expr)):
+        self.engine.setGlobal(name, self.engine.evaluateValue(expr))
+    bytecode_handlers["setGlobal"] = do_setLocal
+
+    def do_beginI18nContext(self, settings):
+        get = settings.get
+        self.i18nContext = TranslationContext(self.i18nContext,
+                                              domain=get("domain"),
+                                              source=get("source"),
+                                              target=get("target"))
+    bytecode_handlers["beginI18nContext"] = do_beginI18nContext
+
+    def do_endI18nContext(self, notused=None):
+        self.i18nContext = self.i18nContext.parent
+        assert self.i18nContext is not None
+    bytecode_handlers["endI18nContext"] = do_endI18nContext
+
+    def do_insertText(self, stuff):
+        self.interpret(stuff[1])
+
+    def do_insertText_tal(self, stuff):
+        text = self.engine.evaluateText(stuff[0])
+        if text is None:
+            return
+        if text is self.Default:
+            self.interpret(stuff[1])
+            return
+        s = escape(text)
+        self._stream_write(s)
+        i = s.rfind('\n')
+        if i < 0:
+            self.col = self.col + len(s)
+        else:
+            self.col = len(s) - (i + 1)
+    bytecode_handlers["insertText"] = do_insertText
+
+    def do_i18nVariable(self, stuff):
+        varname, program, expression = stuff
+        if expression is None:
+            # The value is implicitly the contents of this tag, so we have to
+            # evaluate the mini-program to get the value of the variable.
+            state = self.saveState()
+            try:
+                tmpstream = self.StringIO()
+                self.interpretWithStream(program, tmpstream)
+                value = normalize(tmpstream.getvalue())
+            finally:
+                self.restoreState(state)
+        else:
+            # Evaluate the value to be associated with the variable in the
+            # i18n interpolation dictionary.
+            value = self.engine.evaluate(expression)
+        # Either the i18n:name tag is nested inside an i18n:translate in which
+        # case the last item on the stack has the i18n dictionary and string
+        # representation, or the i18n:name and i18n:translate attributes are
+        # in the same tag, in which case the i18nStack will be empty.  In that
+        # case we can just output the ${name} to the stream
+        i18ndict, srepr = self.i18nStack[-1]
+        i18ndict[varname] = value
+        placeholder = '${%s}' % varname
+        srepr.append(placeholder)
+        self._stream_write(placeholder)
+    bytecode_handlers['i18nVariable'] = do_i18nVariable
+
+    def do_insertTranslation(self, stuff):
+        i18ndict = {}
+        srepr = []
+        obj = None
+        self.i18nStack.append((i18ndict, srepr))
+        msgid = stuff[0]
+        # We need to evaluate the content of the tag because that will give us
+        # several useful pieces of information.  First, the contents will
+        # include an implicit message id, if no explicit one was given.
+        # Second, it will evaluate any i18nVariable definitions in the body of
+        # the translation (necessary for $varname substitutions).
+        #
+        # Use a temporary stream to capture the interpretation of the
+        # subnodes, which should /not/ go to the output stream.
+        tmpstream = self.StringIO()
+        self.interpretWithStream(stuff[1], tmpstream)
+        default = tmpstream.getvalue()
+        # We only care about the evaluated contents if we need an implicit
+        # message id.  All other useful information will be in the i18ndict on
+        # the top of the i18nStack.
+        if msgid == '':
+            msgid = normalize(default)
+        self.i18nStack.pop()
+        # See if there is was an i18n:data for msgid
+        if len(stuff) > 2:
+            obj = self.engine.evaluate(stuff[2])
+        xlated_msgid = self.translate(msgid, default, i18ndict, obj)
+        assert xlated_msgid is not None, self.position
+        self._stream_write(xlated_msgid)
+    bytecode_handlers['insertTranslation'] = do_insertTranslation
+
+    def do_insertStructure(self, stuff):
+        self.interpret(stuff[2])
+
+    def do_insertStructure_tal(self, (expr, repldict, block)):
+        structure = self.engine.evaluateStructure(expr)
+        if structure is None:
+            return
+        if structure is self.Default:
+            self.interpret(block)
+            return
+        text = ustr(structure)
+        if not (repldict or self.strictinsert):
+            # Take a shortcut, no error checking
+            self.stream_write(text)
+            return
+        if self.html:
+            self.insertHTMLStructure(text, repldict)
+        else:
+            self.insertXMLStructure(text, repldict)
+    bytecode_handlers["insertStructure"] = do_insertStructure
+
+    def insertHTMLStructure(self, text, repldict):
+        from HTMLTALParser import HTMLTALParser
+        gen = AltTALGenerator(repldict, self.engine.getCompiler(), 0)
+        p = HTMLTALParser(gen) # Raises an exception if text is invalid
+        p.parseString(text)
+        program, macros = p.getCode()
+        self.interpret(program)
+
+    def insertXMLStructure(self, text, repldict):
+        from TALParser import TALParser
+        gen = AltTALGenerator(repldict, self.engine.getCompiler(), 0)
+        p = TALParser(gen)
+        gen.enable(0)
+        p.parseFragment('<!DOCTYPE foo PUBLIC "foo" "bar"><foo>')
+        gen.enable(1)
+        p.parseFragment(text) # Raises an exception if text is invalid
+        gen.enable(0)
+        p.parseFragment('</foo>', 1)
+        program, macros = gen.getCode()
+        self.interpret(program)
+
+    def do_loop(self, (name, expr, block)):
+        self.interpret(block)
+
+    def do_loop_tal(self, (name, expr, block)):
+        iterator = self.engine.setRepeat(name, expr)
+        while iterator.next():
+            self.interpret(block)
+    bytecode_handlers["loop"] = do_loop
+
+    def translate(self, msgid, default, i18ndict, obj=None):
+        if obj:
+            i18ndict.update(obj)
+        if not self.i18nInterpolate:
+            return msgid
+        # XXX We need to pass in one of context or target_language
+        return self.engine.translate(self.i18nContext.domain,
+                                     msgid, i18ndict, default=default)
+
+    def do_rawtextColumn(self, (s, col)):
+        self._stream_write(s)
+        self.col = col
+    bytecode_handlers["rawtextColumn"] = do_rawtextColumn
+
+    def do_rawtextOffset(self, (s, offset)):
+        self._stream_write(s)
+        self.col = self.col + offset
+    bytecode_handlers["rawtextOffset"] = do_rawtextOffset
+
+    def do_condition(self, (condition, block)):
+        if not self.tal or self.engine.evaluateBoolean(condition):
+            self.interpret(block)
+    bytecode_handlers["condition"] = do_condition
+
+    def do_defineMacro(self, (macroName, macro)):
+        macs = self.macroStack
+        if len(macs) == 1:
+            entering = macs[-1][2]
+            if not entering:
+                macs.append(None)
+                self.interpret(macro)
+                assert macs[-1] is None
+                macs.pop()
+                return
+        self.interpret(macro)
+    bytecode_handlers["defineMacro"] = do_defineMacro
+
+    def do_useMacro(self, (macroName, macroExpr, compiledSlots, block)):
+        if not self.metal:
+            self.interpret(block)
+            return
+        macro = self.engine.evaluateMacro(macroExpr)
+        if macro is self.Default:
+            macro = block
+        else:
+            if not isCurrentVersion(macro):
+                raise METALError("macro %s has incompatible version %s" %
+                                 (`macroName`, `getProgramVersion(macro)`),
+                                 self.position)
+            mode = getProgramMode(macro)
+            if mode != (self.html and "html" or "xml"):
+                raise METALError("macro %s has incompatible mode %s" %
+                                 (`macroName`, `mode`), self.position)
+        self.pushMacro(macroName, compiledSlots)
+        prev_source = self.sourceFile
+        self.interpret(macro)
+        if self.sourceFile != prev_source:
+            self.engine.setSourceFile(prev_source)
+            self.sourceFile = prev_source
+        self.popMacro()
+    bytecode_handlers["useMacro"] = do_useMacro
+
+    def do_fillSlot(self, (slotName, block)):
+        # This is only executed if the enclosing 'use-macro' evaluates
+        # to 'default'.
+        self.interpret(block)
+    bytecode_handlers["fillSlot"] = do_fillSlot
+
+    def do_defineSlot(self, (slotName, block)):
+        if not self.metal:
+            self.interpret(block)
+            return
+        macs = self.macroStack
+        if macs and macs[-1] is not None:
+            macroName, slots = self.popMacro()[:2]
+            slot = slots.get(slotName)
+            if slot is not None:
+                prev_source = self.sourceFile
+                self.interpret(slot)
+                if self.sourceFile != prev_source:
+                    self.engine.setSourceFile(prev_source)
+                    self.sourceFile = prev_source
+                self.pushMacro(macroName, slots, entering=0)
+                return
+            self.pushMacro(macroName, slots)
+            # Falling out of the 'if' allows the macro to be interpreted.
+        self.interpret(block)
+    bytecode_handlers["defineSlot"] = do_defineSlot
+
+    def do_onError(self, (block, handler)):
+        self.interpret(block)
+
+    def do_onError_tal(self, (block, handler)):
+        state = self.saveState()
+        self.stream = stream = self.StringIO()
+        self._stream_write = stream.write
+        try:
+            self.interpret(block)
+        except:
+            exc = sys.exc_info()[1]
+            self.restoreState(state)
+            engine = self.engine
+            engine.beginScope()
+            error = engine.createErrorInfo(exc, self.position)
+            engine.setLocal('error', error)
+            try:
+                self.interpret(handler)
+            finally:
+                engine.endScope()
+        else:
+            self.restoreOutputState(state)
+            self.stream_write(stream.getvalue())
+    bytecode_handlers["onError"] = do_onError
+
+    bytecode_handlers_tal = bytecode_handlers.copy()
+    bytecode_handlers_tal["rawtextBeginScope"] = do_rawtextBeginScope_tal
+    bytecode_handlers_tal["beginScope"] = do_beginScope_tal
+    bytecode_handlers_tal["setLocal"] = do_setLocal_tal
+    bytecode_handlers_tal["setGlobal"] = do_setGlobal_tal
+    bytecode_handlers_tal["insertStructure"] = do_insertStructure_tal
+    bytecode_handlers_tal["insertText"] = do_insertText_tal
+    bytecode_handlers_tal["loop"] = do_loop_tal
+    bytecode_handlers_tal["onError"] = do_onError_tal
+    bytecode_handlers_tal["<attrAction>"] = attrAction_tal
+    bytecode_handlers_tal["optTag"] = do_optTag_tal
+
+
+class FasterStringIO(StringIO):
+    """Append-only version of StringIO.
+
+    This let's us have a much faster write() method.
+    """
+    def close(self):
+        if not self.closed:
+            self.write = _write_ValueError
+            StringIO.close(self)
+
+    def seek(self, pos, mode=0):
+        raise RuntimeError("FasterStringIO.seek() not allowed")
+
+    def write(self, s):
+        #assert self.pos == self.len
+        self.buflist.append(s)
+        self.len = self.pos = self.pos + len(s)
+
+
+def _write_ValueError(s):
+    raise ValueError, "I/O operation on closed file"

Added: tracker/vendor/roundup/current/roundup/cgi/TAL/TALParser.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/TAL/TALParser.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,144 @@
+##############################################################################
+#
+# Copyright (c) 2001, 2002 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.0 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+"""
+Parse XML and compile to TALInterpreter intermediate code.
+"""
+
+from XMLParser import XMLParser
+from TALDefs import XML_NS, ZOPE_I18N_NS, ZOPE_METAL_NS, ZOPE_TAL_NS
+from TALGenerator import TALGenerator
+
+class TALParser(XMLParser):
+
+    ordered_attributes = 1
+
+    def __init__(self, gen=None): # Override
+        XMLParser.__init__(self)
+        if gen is None:
+            gen = TALGenerator()
+        self.gen = gen
+        self.nsStack = []
+        self.nsDict = {XML_NS: 'xml'}
+        self.nsNew = []
+
+    def getCode(self):
+        return self.gen.getCode()
+
+    def getWarnings(self):
+        return ()
+
+    def StartNamespaceDeclHandler(self, prefix, uri):
+        self.nsStack.append(self.nsDict.copy())
+        self.nsDict[uri] = prefix
+        self.nsNew.append((prefix, uri))
+
+    def EndNamespaceDeclHandler(self, prefix):
+        self.nsDict = self.nsStack.pop()
+
+    def StartElementHandler(self, name, attrs):
+        if self.ordered_attributes:
+            # attrs is a list of alternating names and values
+            attrlist = []
+            for i in range(0, len(attrs), 2):
+                key = attrs[i]
+                value = attrs[i+1]
+                attrlist.append((key, value))
+        else:
+            # attrs is a dict of {name: value}
+            attrlist = attrs.items()
+            attrlist.sort() # For definiteness
+        name, attrlist, taldict, metaldict, i18ndict \
+              = self.process_ns(name, attrlist)
+        attrlist = self.xmlnsattrs() + attrlist
+        self.gen.emitStartElement(name, attrlist, taldict, metaldict, i18ndict)
+
+    def process_ns(self, name, attrlist):
+        taldict = {}
+        metaldict = {}
+        i18ndict = {}
+        fixedattrlist = []
+        name, namebase, namens = self.fixname(name)
+        for key, value in attrlist:
+            key, keybase, keyns = self.fixname(key)
+            ns = keyns or namens # default to tag namespace
+            item = key, value
+            if ns == 'metal':
+                metaldict[keybase] = value
+                item = item + ("metal",)
+            elif ns == 'tal':
+                taldict[keybase] = value
+                item = item + ("tal",)
+            elif ns == 'i18n':
+                assert 0, "dealing with i18n: " + `(keybase, value)`
+                i18ndict[keybase] = value
+                item = item + ('i18n',)
+            fixedattrlist.append(item)
+        if namens in ('metal', 'tal', 'i18n'):
+            taldict['tal tag'] = namens
+        return name, fixedattrlist, taldict, metaldict, i18ndict
+
+    def xmlnsattrs(self):
+        newlist = []
+        for prefix, uri in self.nsNew:
+            if prefix:
+                key = "xmlns:" + prefix
+            else:
+                key = "xmlns"
+            if uri in (ZOPE_METAL_NS, ZOPE_TAL_NS, ZOPE_I18N_NS):
+                item = (key, uri, "xmlns")
+            else:
+                item = (key, uri)
+            newlist.append(item)
+        self.nsNew = []
+        return newlist
+
+    def fixname(self, name):
+        if ' ' in name:
+            uri, name = name.split(' ')
+            prefix = self.nsDict[uri]
+            prefixed = name
+            if prefix:
+                prefixed = "%s:%s" % (prefix, name)
+            ns = 'x'
+            if uri == ZOPE_TAL_NS:
+                ns = 'tal'
+            elif uri == ZOPE_METAL_NS:
+                ns = 'metal'
+            elif uri == ZOPE_I18N_NS:
+                ns = 'i18n'
+            return (prefixed, name, ns)
+        return (name, name, None)
+
+    def EndElementHandler(self, name):
+        name = self.fixname(name)[0]
+        self.gen.emitEndElement(name)
+
+    def DefaultHandler(self, text):
+        self.gen.emitRawText(text)
+
+def test():
+    import sys
+    p = TALParser()
+    file = "tests/input/test01.xml"
+    if sys.argv[1:]:
+        file = sys.argv[1]
+    p.parseFile(file)
+    program, macros = p.getCode()
+    from TALInterpreter import TALInterpreter
+    from DummyEngine import DummyEngine
+    engine = DummyEngine(macros)
+    TALInterpreter(program, macros, engine, sys.stdout, wrap=0)()
+
+if __name__ == "__main__":
+    test()

Added: tracker/vendor/roundup/current/roundup/cgi/TAL/TranslationContext.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/TAL/TranslationContext.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,41 @@
+##############################################################################
+#
+# Copyright (c) 2001, 2002 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.0 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+"""Translation context object for the TALInterpreter's I18N support.
+
+The translation context provides a container for the information
+needed to perform translation of a marked string from a page template.
+
+$Id: TranslationContext.py,v 1.1 2004/05/21 05:36:30 richard Exp $
+"""
+
+DEFAULT_DOMAIN = "default"
+
+class TranslationContext:
+    """Information about the I18N settings of a TAL processor."""
+
+    def __init__(self, parent=None, domain=None, target=None, source=None):
+        if parent:
+            if not domain:
+                domain = parent.domain
+            if not target:
+                target = parent.target
+            if not source:
+                source = parent.source
+        elif domain is None:
+            domain = DEFAULT_DOMAIN
+
+        self.parent = parent
+        self.domain = domain
+        self.target = target
+        self.source = source

Added: tracker/vendor/roundup/current/roundup/cgi/TAL/XMLParser.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/TAL/XMLParser.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,93 @@
+##############################################################################
+#
+# Copyright (c) 2001, 2002 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.0 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE
+#
+##############################################################################
+# Modifications for Roundup:
+# 1. commented out zLOG references
+"""
+Generic expat-based XML parser base class.
+"""
+
+#import zLOG
+
+class XMLParser:
+
+    ordered_attributes = 0
+
+    handler_names = [
+        "StartElementHandler",
+        "EndElementHandler",
+        "ProcessingInstructionHandler",
+        "CharacterDataHandler",
+        "UnparsedEntityDeclHandler",
+        "NotationDeclHandler",
+        "StartNamespaceDeclHandler",
+        "EndNamespaceDeclHandler",
+        "CommentHandler",
+        "StartCdataSectionHandler",
+        "EndCdataSectionHandler",
+        "DefaultHandler",
+        "DefaultHandlerExpand",
+        "NotStandaloneHandler",
+        "ExternalEntityRefHandler",
+        "XmlDeclHandler",
+        "StartDoctypeDeclHandler",
+        "EndDoctypeDeclHandler",
+        "ElementDeclHandler",
+        "AttlistDeclHandler"
+        ]
+
+    def __init__(self, encoding=None):
+        self.parser = p = self.createParser()
+        if self.ordered_attributes:
+            try:
+                self.parser.ordered_attributes = self.ordered_attributes
+            except AttributeError:
+                #zLOG.LOG("TAL.XMLParser", zLOG.INFO, 
+                #         "Can't set ordered_attributes")
+                self.ordered_attributes = 0
+        for name in self.handler_names:
+            method = getattr(self, name, None)
+            if method is not None:
+                try:
+                    setattr(p, name, method)
+                except AttributeError:
+                    #zLOG.LOG("TAL.XMLParser", zLOG.PROBLEM,
+                    #         "Can't set expat handler %s" % name)
+                    pass
+
+    def createParser(self, encoding=None):
+        global XMLParseError
+        try:
+            from Products.ParsedXML.Expat import pyexpat
+            XMLParseError = pyexpat.ExpatError
+            return pyexpat.ParserCreate(encoding, ' ')
+        except ImportError:
+            from xml.parsers import expat
+            XMLParseError = expat.ExpatError
+            return expat.ParserCreate(encoding, ' ')
+
+    def parseFile(self, filename):
+        self.parseStream(open(filename))
+
+    def parseString(self, s):
+        self.parser.Parse(s, 1)
+
+    def parseURL(self, url):
+        import urllib
+        self.parseStream(urllib.urlopen(url))
+
+    def parseStream(self, stream):
+        self.parser.ParseFile(stream)
+
+    def parseFragment(self, s, end=0):
+        self.parser.Parse(s, end)

Added: tracker/vendor/roundup/current/roundup/cgi/TAL/__init__.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/TAL/__init__.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,14 @@
+##############################################################################
+#
+# Copyright (c) 2001, 2002 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.0 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE
+#
+##############################################################################
+""" Template Attribute Language package """

Added: tracker/vendor/roundup/current/roundup/cgi/TAL/markupbase.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/TAL/markupbase.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,312 @@
+"""Shared support for scanning document type declarations in HTML and XHTML."""
+
+import re, string
+
+_declname_match = re.compile(r'[a-zA-Z][-_.a-zA-Z0-9]*\s*').match
+_declstringlit_match = re.compile(r'(\'[^\']*\'|"[^"]*")\s*').match
+
+del re
+
+
+class ParserBase:
+    """Parser base class which provides some common support methods used
+    by the SGML/HTML and XHTML parsers."""
+
+    def reset(self):
+        self.lineno = 1
+        self.offset = 0
+
+    def getpos(self):
+        """Return current line number and offset."""
+        return self.lineno, self.offset
+
+    def error(self, message):
+        """Return an error, showing current line number and offset.
+
+        Concrete subclasses *must* override this method.
+        """
+        raise NotImplementedError
+
+    # Internal -- update line number and offset.  This should be
+    # called for each piece of data exactly once, in order -- in other
+    # words the concatenation of all the input strings to this
+    # function should be exactly the entire input.
+    def updatepos(self, i, j):
+        if i >= j:
+            return j
+        rawdata = self.rawdata
+        nlines = rawdata.count("\n", i, j)
+        if nlines:
+            self.lineno = self.lineno + nlines
+            pos = rawdata.rindex("\n", i, j) # Should not fail
+            self.offset = j-(pos+1)
+        else:
+            self.offset = self.offset + j-i
+        return j
+
+    _decl_otherchars = ''
+
+    # Internal -- parse declaration (for use by subclasses).
+    def parse_declaration(self, i):
+        # This is some sort of declaration; in "HTML as
+        # deployed," this should only be the document type
+        # declaration ("<!DOCTYPE html...>").
+        rawdata = self.rawdata
+        import sys
+        j = i + 2
+        assert rawdata[i:j] == "<!", "unexpected call to parse_declaration"
+        if rawdata[j:j+1] in ("-", ""):
+            # Start of comment followed by buffer boundary,
+            # or just a buffer boundary.
+            return -1
+        # in practice, this should look like: ((name|stringlit) S*)+ '>'
+        n = len(rawdata)
+        decltype, j = self._scan_name(j, i)
+        if j < 0:
+            return j
+        if decltype == "doctype":
+            self._decl_otherchars = ''
+        while j < n:
+            c = rawdata[j]
+            if c == ">":
+                # end of declaration syntax
+                data = rawdata[i+2:j]
+                if decltype == "doctype":
+                    self.handle_decl(data)
+                else:
+                    self.unknown_decl(data)
+                return j + 1
+            if c in "\"'":
+                m = _declstringlit_match(rawdata, j)
+                if not m:
+                    return -1 # incomplete
+                j = m.end()
+            elif c in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ":
+                name, j = self._scan_name(j, i)
+            elif c in self._decl_otherchars:
+                j = j + 1
+            elif c == "[":
+                if decltype == "doctype":
+                    j = self._parse_doctype_subset(j + 1, i)
+                else:
+                    self.error("unexpected '[' char in declaration")
+            else:
+                self.error(
+                    "unexpected %s char in declaration" % `rawdata[j]`)
+            if j < 0:
+                return j
+        return -1 # incomplete
+
+    # Internal -- scan past the internal subset in a <!DOCTYPE declaration,
+    # returning the index just past any whitespace following the trailing ']'.
+    def _parse_doctype_subset(self, i, declstartpos):
+        rawdata = self.rawdata
+        n = len(rawdata)
+        j = i
+        while j < n:
+            c = rawdata[j]
+            if c == "<":
+                s = rawdata[j:j+2]
+                if s == "<":
+                    # end of buffer; incomplete
+                    return -1
+                if s != "<!":
+                    self.updatepos(declstartpos, j + 1)
+                    self.error("unexpected char in internal subset (in %s)"
+                               % `s`)
+                if (j + 2) == n:
+                    # end of buffer; incomplete
+                    return -1
+                if (j + 4) > n:
+                    # end of buffer; incomplete
+                    return -1
+                if rawdata[j:j+4] == "<!--":
+                    j = self.parse_comment(j, report=0)
+                    if j < 0:
+                        return j
+                    continue
+                name, j = self._scan_name(j + 2, declstartpos)
+                if j == -1:
+                    return -1
+                if name not in ("attlist", "element", "entity", "notation"):
+                    self.updatepos(declstartpos, j + 2)
+                    self.error(
+                        "unknown declaration %s in internal subset" % `name`)
+                # handle the individual names
+                meth = getattr(self, "_parse_doctype_" + name)
+                j = meth(j, declstartpos)
+                if j < 0:
+                    return j
+            elif c == "%":
+                # parameter entity reference
+                if (j + 1) == n:
+                    # end of buffer; incomplete
+                    return -1
+                s, j = self._scan_name(j + 1, declstartpos)
+                if j < 0:
+                    return j
+                if rawdata[j] == ";":
+                    j = j + 1
+            elif c == "]":
+                j = j + 1
+                while j < n and rawdata[j] in string.whitespace:
+                    j = j + 1
+                if j < n:
+                    if rawdata[j] == ">":
+                        return j
+                    self.updatepos(declstartpos, j)
+                    self.error("unexpected char after internal subset")
+                else:
+                    return -1
+            elif c in string.whitespace:
+                j = j + 1
+            else:
+                self.updatepos(declstartpos, j)
+                self.error("unexpected char %s in internal subset" % `c`)
+        # end of buffer reached
+        return -1
+
+    # Internal -- scan past <!ELEMENT declarations
+    def _parse_doctype_element(self, i, declstartpos):
+        rawdata = self.rawdata
+        n = len(rawdata)
+        name, j = self._scan_name(i, declstartpos)
+        if j == -1:
+            return -1
+        # style content model; just skip until '>'
+        if '>' in rawdata[j:]:
+            return rawdata.find(">", j) + 1
+        return -1
+
+    # Internal -- scan past <!ATTLIST declarations
+    def _parse_doctype_attlist(self, i, declstartpos):
+        rawdata = self.rawdata
+        name, j = self._scan_name(i, declstartpos)
+        c = rawdata[j:j+1]
+        if c == "":
+            return -1
+        if c == ">":
+            return j + 1
+        while 1:
+            # scan a series of attribute descriptions; simplified:
+            #   name type [value] [#constraint]
+            name, j = self._scan_name(j, declstartpos)
+            if j < 0:
+                return j
+            c = rawdata[j:j+1]
+            if c == "":
+                return -1
+            if c == "(":
+                # an enumerated type; look for ')'
+                if ")" in rawdata[j:]:
+                    j = rawdata.find(")", j) + 1
+                else:
+                    return -1
+                while rawdata[j:j+1].isspace():
+                    j = j + 1
+                if not rawdata[j:]:
+                    # end of buffer, incomplete
+                    return -1
+            else:
+                name, j = self._scan_name(j, declstartpos)
+            c = rawdata[j:j+1]
+            if not c:
+                return -1
+            if c in "'\"":
+                m = _declstringlit_match(rawdata, j)
+                if m:
+                    j = m.end()
+                else:
+                    return -1
+                c = rawdata[j:j+1]
+                if not c:
+                    return -1
+            if c == "#":
+                if rawdata[j:] == "#":
+                    # end of buffer
+                    return -1
+                name, j = self._scan_name(j + 1, declstartpos)
+                if j < 0:
+                    return j
+                c = rawdata[j:j+1]
+                if not c:
+                    return -1
+            if c == '>':
+                # all done
+                return j + 1
+
+    # Internal -- scan past <!NOTATION declarations
+    def _parse_doctype_notation(self, i, declstartpos):
+        name, j = self._scan_name(i, declstartpos)
+        if j < 0:
+            return j
+        rawdata = self.rawdata
+        while 1:
+            c = rawdata[j:j+1]
+            if not c:
+                # end of buffer; incomplete
+                return -1
+            if c == '>':
+                return j + 1
+            if c in "'\"":
+                m = _declstringlit_match(rawdata, j)
+                if not m:
+                    return -1
+                j = m.end()
+            else:
+                name, j = self._scan_name(j, declstartpos)
+                if j < 0:
+                    return j
+
+    # Internal -- scan past <!ENTITY declarations
+    def _parse_doctype_entity(self, i, declstartpos):
+        rawdata = self.rawdata
+        if rawdata[i:i+1] == "%":
+            j = i + 1
+            while 1:
+                c = rawdata[j:j+1]
+                if not c:
+                    return -1
+                if c in string.whitespace:
+                    j = j + 1
+                else:
+                    break
+        else:
+            j = i
+        name, j = self._scan_name(j, declstartpos)
+        if j < 0:
+            return j
+        while 1:
+            c = self.rawdata[j:j+1]
+            if not c:
+                return -1
+            if c in "'\"":
+                m = _declstringlit_match(rawdata, j)
+                if m:
+                    j = m.end()
+                else:
+                    return -1    # incomplete
+            elif c == ">":
+                return j + 1
+            else:
+                name, j = self._scan_name(j, declstartpos)
+                if j < 0:
+                    return j
+
+    # Internal -- scan a name token and the new position and the token, or
+    # return -1 if we've reached the end of the buffer.
+    def _scan_name(self, i, declstartpos):
+        rawdata = self.rawdata
+        n = len(rawdata)
+        if i == n:
+            return None, -1
+        m = _declname_match(rawdata, i)
+        if m:
+            s = m.group()
+            name = s.strip()
+            if (i + len(s)) == n:
+                return None, -1  # end of buffer
+            return name.lower(), m.end()
+        else:
+            self.updatepos(declstartpos, i)
+            self.error("expected name token")

Added: tracker/vendor/roundup/current/roundup/cgi/TAL/talgettext.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/TAL/talgettext.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,318 @@
+#!/usr/bin/env python
+##############################################################################
+#
+# Copyright (c) 2002 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.0 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+# Modifications for Roundup:
+# 1. commented out ITALES references
+# 2. escape quotes and line feeds in msgids
+# 3. don't collect empty msgids
+
+"""Program to extract internationalization markup from Page Templates.
+
+Once you have marked up a Page Template file with i18n: namespace tags, use
+this program to extract GNU gettext .po file entries.
+
+Usage: talgettext.py [options] files
+Options:
+    -h / --help
+        Print this message and exit.
+    -o / --output <file>
+        Output the translation .po file to <file>.
+    -u / --update <file>
+        Update the existing translation <file> with any new translation strings
+        found.
+"""
+
+import sys
+import time
+import getopt
+import traceback
+
+from roundup.cgi.TAL.HTMLTALParser import HTMLTALParser
+from roundup.cgi.TAL.TALInterpreter import TALInterpreter
+from roundup.cgi.TAL.DummyEngine import DummyEngine
+#from ITALES import ITALESEngine
+from roundup.cgi.TAL.TALDefs import TALESError
+
+__version__ = '$Revision: 1.6 $'
+
+pot_header = '''\
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR ORGANIZATION
+# FIRST AUTHOR <EMAIL at ADDRESS>, YEAR.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\\n"
+"POT-Creation-Date: %(time)s\\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n"
+"Last-Translator: FULL NAME <EMAIL at ADDRESS>\\n"
+"Language-Team: LANGUAGE <LL at li.org>\\n"
+"MIME-Version: 1.0\\n"
+"Content-Type: text/plain; charset=CHARSET\\n"
+"Content-Transfer-Encoding: ENCODING\\n"
+"Generated-By: talgettext.py %(version)s\\n"
+'''
+
+NLSTR = '"\n"'
+
+try:
+    True
+except NameError:
+    True=1
+    False=0
+
+def usage(code, msg=''):
+    # Python 2.1 required
+    print >> sys.stderr, __doc__
+    if msg:
+        print >> sys.stderr, msg
+    sys.exit(code)
+
+
+class POTALInterpreter(TALInterpreter):
+    def translate(self, msgid, default, i18ndict=None, obj=None):
+        # XXX is this right?
+        if i18ndict is None:
+            i18ndict = {}
+        if obj:
+            i18ndict.update(obj)
+        # XXX Mmmh, it seems that sometimes the msgid is None; is that really
+        # possible?
+        if msgid is None:
+            return None
+        # XXX We need to pass in one of context or target_language
+        return self.engine.translate(msgid, self.i18nContext.domain, i18ndict,
+                                     position=self.position, default=default)
+
+
+class POEngine(DummyEngine):
+    #__implements__ = ITALESEngine
+
+    def __init__(self, macros=None):
+        self.catalog = {}
+        DummyEngine.__init__(self, macros)
+
+    def evaluate(*args):
+        return '' # who cares
+
+    def evaluatePathOrVar(*args):
+        return '' # who cares
+
+    def evaluateSequence(self, expr):
+        return (0,) # dummy
+
+    def evaluateBoolean(self, expr):
+        return True # dummy
+
+    def translate(self, msgid, domain=None, mapping=None, default=None,
+                  # XXX position is not part of the ITALESEngine
+                  #     interface
+                  position=None):
+
+        if not msgid: return 'x'
+
+        if domain not in self.catalog:
+            self.catalog[domain] = {}
+        domain = self.catalog[domain]
+
+        if msgid not in domain:
+            domain[msgid] = []
+        domain[msgid].append((self.file, position))
+        return 'x'
+
+
+class UpdatePOEngine(POEngine):
+    """A slightly-less braindead POEngine which supports loading an existing
+    .po file first."""
+
+    def __init__ (self, macros=None, filename=None):
+        POEngine.__init__(self, macros)
+
+        self._filename = filename
+        self._loadFile()
+        self.base = self.catalog
+        self.catalog = {}
+
+    def __add(self, id, s, fuzzy):
+        "Add a non-fuzzy translation to the dictionary."
+        if not fuzzy and str:
+            # check for multi-line values and munge them appropriately
+            if '\n' in s:
+                lines = s.rstrip().split('\n')
+                s = NLSTR.join(lines)
+            self.catalog[id] = s
+
+    def _loadFile(self):
+        # shamelessly cribbed from Python's Tools/i18n/msgfmt.py
+        # 25-Mar-2003 Nathan R. Yergler (nathan at zope.org)
+        # 14-Apr-2003 Hacked by Barry Warsaw (barry at zope.com)
+
+        ID = 1
+        STR = 2
+
+        try:
+            lines = open(self._filename).readlines()
+        except IOError, msg:
+            print >> sys.stderr, msg
+            sys.exit(1)
+
+        section = None
+        fuzzy = False
+
+        # Parse the catalog
+        lno = 0
+        for l in lines:
+            lno += True
+            # If we get a comment line after a msgstr, this is a new entry
+            if l[0] == '#' and section == STR:
+                self.__add(msgid, msgstr, fuzzy)
+                section = None
+                fuzzy = False
+            # Record a fuzzy mark
+            if l[:2] == '#,' and l.find('fuzzy'):
+                fuzzy = True
+            # Skip comments
+            if l[0] == '#':
+                continue
+            # Now we are in a msgid section, output previous section
+            if l.startswith('msgid'):
+                if section == STR:
+                    self.__add(msgid, msgstr, fuzzy)
+                section = ID
+                l = l[5:]
+                msgid = msgstr = ''
+            # Now we are in a msgstr section
+            elif l.startswith('msgstr'):
+                section = STR
+                l = l[6:]
+            # Skip empty lines
+            if not l.strip():
+                continue
+            # XXX: Does this always follow Python escape semantics?
+            l = eval(l)
+            if section == ID:
+                msgid += l
+            elif section == STR:
+                msgstr += '%s\n' % l
+            else:
+                print >> sys.stderr, 'Syntax error on %s:%d' % (infile, lno), \
+                      'before:'
+                print >> sys.stderr, l
+                sys.exit(1)
+        # Add last entry
+        if section == STR:
+            self.__add(msgid, msgstr, fuzzy)
+
+    def evaluate(self, expression):
+        try:
+            return POEngine.evaluate(self, expression)
+        except TALESError:
+            pass
+
+    def evaluatePathOrVar(self, expr):
+        return 'who cares'
+
+    def translate(self, msgid, domain=None, mapping=None, default=None,
+                  position=None):
+        if msgid not in self.base:
+            POEngine.translate(self, msgid, domain, mapping, default, position)
+        return 'x'
+
+
+def main():
+    try:
+        opts, args = getopt.getopt(
+            sys.argv[1:],
+            'ho:u:',
+            ['help', 'output=', 'update='])
+    except getopt.error, msg:
+        usage(1, msg)
+
+    outfile = None
+    engine = None
+    update_mode = False
+    for opt, arg in opts:
+        if opt in ('-h', '--help'):
+            usage(0)
+        elif opt in ('-o', '--output'):
+            outfile = arg
+        elif opt in ('-u', '--update'):
+            update_mode = True
+            if outfile is None:
+                outfile = arg
+            engine = UpdatePOEngine(filename=arg)
+
+    if not args:
+        print 'nothing to do'
+        return
+
+    # We don't care about the rendered output of the .pt file
+    class Devnull:
+        def write(self, s):
+            pass
+
+    # check if we've already instantiated an engine;
+    # if not, use the stupidest one available
+    if not engine:
+        engine = POEngine()
+
+    # process each file specified
+    for filename in args:
+        try:
+            engine.file = filename
+            p = HTMLTALParser()
+            p.parseFile(filename)
+            program, macros = p.getCode()
+            POTALInterpreter(program, macros, engine, stream=Devnull(),
+                             metal=False)()
+        except: # Hee hee, I love bare excepts!
+            print 'There was an error processing', filename
+            traceback.print_exc()
+
+    # Now output the keys in the engine.  Write them to a file if --output or
+    # --update was specified; otherwise use standard out.
+    if (outfile is None):
+        outfile = sys.stdout
+    else:
+        outfile = file(outfile, update_mode and "a" or "w")
+
+    catalog = {}
+    for domain in engine.catalog.keys():
+        catalog.update(engine.catalog[domain])
+
+    messages = catalog.copy()
+    try:
+        messages.update(engine.base)
+    except AttributeError:
+        pass
+    if '' not in messages:
+        print >> outfile, pot_header % {'time': time.ctime(),
+                                        'version': __version__}
+
+    msgids = catalog.keys()
+    # XXX: You should not sort by msgid, but by filename and position. (SR)
+    msgids.sort()
+    for msgid in msgids:
+        positions = catalog[msgid]
+        for filename, position in positions:
+            outfile.write('#: %s:%s\n' % (filename, position[0]))
+
+        outfile.write('msgid "%s"\n'
+            % msgid.replace('"', '\\"').replace("\n", '\\n"\n"'))
+        outfile.write('msgstr ""\n')
+        outfile.write('\n')
+
+
+if __name__ == '__main__':
+    main()

Added: tracker/vendor/roundup/current/roundup/cgi/TranslationService.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/TranslationService.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,111 @@
+# TranslationService for Roundup templates
+#
+# This module is free software, you may redistribute it
+# and/or modify under the same terms as Python.
+#
+# This module provides National Language Support
+# for Roundup templating - much like roundup.i18n
+# module for Roundup command line interface.
+# The only difference is that translator objects
+# returned by get_translation() have one additional
+# method which is used by TAL engines:
+#
+#   translate(domain, msgid, mapping, context, target_language, default)
+#
+
+__version__ = "$Revision: 1.2 $"[11:-2]
+__date__ = "$Date: 2004/10/23 14:04:23 $"[7:-2]
+
+from roundup import i18n
+from roundup.cgi.PageTemplates import Expressions, PathIterator, TALES
+from roundup.cgi.TAL import TALInterpreter
+
+### Translation classes
+
+class TranslationServiceMixin:
+
+    OUTPUT_ENCODING = "utf-8"
+
+    def translate(self, domain, msgid, mapping=None,
+        context=None, target_language=None, default=None
+    ):
+        _msg = self.gettext(msgid)
+        #print ("TRANSLATE", msgid, _msg, mapping, context)
+        _msg = TALInterpreter.interpolate(_msg, mapping)
+        return _msg
+
+    def gettext(self, msgid):
+        return self.ugettext(msgid).encode(self.OUTPUT_ENCODING)
+
+    def ngettext(self, singular, plural, number):
+        return self.ungettext(singular, plural, number).encode(
+            self.OUTPUT_ENCODING)
+
+class TranslationService(TranslationServiceMixin, i18n.RoundupTranslations):
+    pass
+
+class NullTranslationService(TranslationServiceMixin,
+    i18n.RoundupNullTranslations
+):
+    pass
+
+### TAL patching
+#
+# Template Attribute Language (TAL) uses only global translation service,
+# which is not thread-safe.  We will use context variable 'i18n'
+# to access request-dependent transalation service (with domain
+# and target language set during initializations of the roundup
+# client interface.
+#
+
+class Context(TALES.Context):
+
+    def __init__(self, compiler, contexts):
+        TALES.Context.__init__(self, compiler, contexts)
+        if not self.contexts.get('i18n', None):
+            # if the context contains no TranslationService,
+            # create default one
+            self.contexts['i18n'] = get_translation()
+        self.i18n = self.contexts['i18n']
+
+    def translate(self, domain, msgid, mapping=None,
+                  context=None, target_language=None, default=None):
+        if context is None:
+            context = self.contexts.get('here')
+        return self.i18n.translate(domain, msgid,
+            mapping=mapping, context=context, default=default,
+            target_language=target_language)
+
+class Engine(TALES.Engine):
+
+    def getContext(self, contexts=None, **kwcontexts):
+        if contexts is not None:
+            if kwcontexts:
+                kwcontexts.update(contexts)
+            else:
+                kwcontexts = contexts
+        return Context(self, kwcontexts)
+
+# patching TAL like this is a dirty hack,
+# but i see no other way to specify different Context class
+Expressions._engine = Engine(PathIterator.Iterator)
+Expressions.installHandlers(Expressions._engine)
+
+### main API function
+
+def get_translation(language=None, tracker_home=None,
+    translation_class=TranslationService,
+    null_translation_class=NullTranslationService
+):
+    """Return Translation object for given language and domain
+
+    Arguments 'translation_class' and 'null_translation_class'
+    specify the classes that are instantiated for existing
+    and non-existing translations, respectively.
+    """
+    return i18n.get_translation(language=language,
+        tracker_home=tracker_home,
+        translation_class=translation_class,
+        null_translation_class=null_translation_class)
+
+# vim: set et sts=4 sw=4 :

Added: tracker/vendor/roundup/current/roundup/cgi/ZTUtils/.cvsignore
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/ZTUtils/.cvsignore	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,2 @@
+*.pyc
+*.pyo

Added: tracker/vendor/roundup/current/roundup/cgi/ZTUtils/Batch.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/ZTUtils/Batch.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,120 @@
+##############################################################################
+#
+# Copyright (c) 2001 Zope Corporation and Contributors. All Rights Reserved.
+# 
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.0 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE
+# 
+##############################################################################
+__doc__='''Batch class, for iterating over a sequence in batches
+
+$Id: Batch.py,v 1.3 2004/02/11 23:55:09 richard Exp $'''
+__docformat__ = 'restructuredtext'
+__version__='$Revision: 1.3 $'[11:-2]
+
+class LazyPrevBatch:
+    def __of__(self, parent):
+        return Batch(parent._sequence, parent._size,
+                     parent.first - parent._size + parent.overlap, 0,
+                     parent.orphan, parent.overlap)
+
+class LazyNextBatch:
+    def __of__(self, parent):
+        try: parent._sequence[parent.end]
+        except IndexError: return None
+        return Batch(parent._sequence, parent._size,
+                     parent.end - parent.overlap, 0,
+                     parent.orphan, parent.overlap)
+
+class LazySequenceLength:
+    def __of__(self, parent):
+        parent.sequence_length = l = len(parent._sequence)
+        return l
+
+class Batch:
+    """Create a sequence batch"""
+    __allow_access_to_unprotected_subobjects__ = 1
+
+    previous = LazyPrevBatch()
+    next = LazyNextBatch()
+    sequence_length = LazySequenceLength()
+
+    def __init__(self, sequence, size, start=0, end=0,
+                 orphan=0, overlap=0):
+        '''Encapsulate "sequence" in batches of "size".
+
+        Arguments: "start" and "end" are 0-based indexes into the
+        sequence.  If the next batch would contain no more than
+        "orphan" elements, it is combined with the current batch.
+        "overlap" is the number of elements shared by adjacent
+        batches.  If "size" is not specified, it is computed from
+        "start" and "end".  Failing that, it is 7.
+
+        Attributes: Note that the "start" attribute, unlike the
+        argument, is a 1-based index (I know, lame).  "first" is the
+        0-based index.  "length" is the actual number of elements in
+        the batch.
+
+        "sequence_length" is the length of the original, unbatched, sequence
+        '''
+
+        start = start + 1
+
+        start,end,sz = opt(start,end,size,orphan,sequence)
+
+        self._sequence = sequence
+        self.size = sz
+        self._size = size
+        self.start = start
+        self.end = end
+        self.orphan = orphan
+        self.overlap = overlap
+        self.first = max(start - 1, 0)
+        self.length = self.end - self.first
+        if self.first == 0:
+            self.previous = None
+
+
+    def __getitem__(self, index):
+        if index < 0:
+            if index + self.end < self.first: raise IndexError, index
+            return self._sequence[index + self.end]
+        
+        if index >= self.length: raise IndexError, index
+        return self._sequence[index + self.first]
+
+    def __len__(self):
+        return self.length
+
+def opt(start,end,size,orphan,sequence):
+    if size < 1:
+        if start > 0 and end > 0 and end >= start:
+            size=end+1-start
+        else: size=7
+
+    if start > 0:
+
+        try: sequence[start-1]
+        except IndexError: start=len(sequence)
+
+        if end > 0:
+            if end < start: end=start
+        else:
+            end=start+size-1
+            try: sequence[end+orphan-1]
+            except IndexError: end=len(sequence)
+    elif end > 0:
+        try: sequence[end-1]
+        except IndexError: end=len(sequence)
+        start=end+1-size
+        if start - 1 < orphan: start=1
+    else:
+        start=1
+        end=start+size-1
+        try: sequence[end+orphan-1]
+        except IndexError: end=len(sequence)
+    return start,end,size

Added: tracker/vendor/roundup/current/roundup/cgi/ZTUtils/Iterator.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/ZTUtils/Iterator.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,195 @@
+##############################################################################
+#
+# Copyright (c) 2001 Zope Corporation and Contributors. All Rights Reserved.
+# 
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.0 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE
+# 
+##############################################################################
+__doc__='''Iterator class
+
+Unlike the builtin iterators of Python 2.2+, these classes are
+designed to maintain information about the state of an iteration.
+The Iterator() function accepts either a sequence or a Python
+iterator.  The next() method fetches the next item, and returns
+true if it succeeds.
+
+$Id: Iterator.py,v 1.4 2005/02/16 22:07:33 richard Exp $'''
+__docformat__ = 'restructuredtext'
+__version__='$Revision: 1.4 $'[11:-2]
+
+import string
+
+class Iterator:
+    '''Simple Iterator class'''
+
+    __allow_access_to_unprotected_subobjects__ = 1
+
+    nextIndex = 0
+    def __init__(self, seq):
+        self.seq = iter(seq)     # force seq to be an iterator
+        self._inner = iterInner
+        self._prep_next = iterInner.prep_next
+
+    def __getattr__(self, name):
+        try:
+            inner = getattr(self._inner, 'it_' + name)
+        except AttributeError:
+            raise AttributeError, name
+        return inner(self)
+
+    def next(self):
+        if not (hasattr(self, '_next') or self._prep_next(self)):
+            return 0
+        self.index = i = self.nextIndex
+        self.nextIndex = i+1
+        self._advance(self)
+        return 1
+
+    def _advance(self, it):
+        self.item = self._next
+        del self._next
+        del self.end
+        self._advance = self._inner.advance
+        self.start = 1
+            
+    def number(self): return self.nextIndex
+
+    def even(self): return not self.index % 2
+
+    def odd(self): return self.index % 2
+
+    def letter(self, base=ord('a'), radix=26):
+        index = self.index
+        s = ''
+        while 1:
+            index, off = divmod(index, radix)
+            s = chr(base + off) + s
+            if not index: return s
+
+    def Letter(self):
+        return self.letter(base=ord('A'))
+
+    def Roman(self, rnvalues=(
+                    (1000,'M'),(900,'CM'),(500,'D'),(400,'CD'),
+                    (100,'C'),(90,'XC'),(50,'L'),(40,'XL'),
+                    (10,'X'),(9,'IX'),(5,'V'),(4,'IV'),(1,'I')) ):
+        n = self.index + 1
+        s = ''
+        for v, r in rnvalues:
+            rct, n = divmod(n, v)
+            s = s + r * rct
+        return s
+
+    def roman(self, lower=string.lower):
+        return lower(self.Roman())
+
+    def first(self, name=None):
+        if self.start: return 1
+        return not self.same_part(name, self._last, self.item)
+
+    def last(self, name=None):
+        if self.end: return 1
+        return not self.same_part(name, self.item, self._next)
+
+    def same_part(self, name, ob1, ob2):
+        if name is None:
+            return ob1 == ob2
+        no = []
+        return getattr(ob1, name, no) == getattr(ob2, name, no) is not no
+
+    def __iter__(self):
+        return IterIter(self)
+
+class InnerBase:
+    '''Base Inner class for Iterators'''
+    # Prep sets up ._next and .end
+    def prep_next(self, it):
+        it.next = self.no_next
+        it.end = 1
+        return 0
+
+    # Advance knocks them down
+    def advance(self, it):
+        it._last = it.item
+        it.item = it._next
+        del it._next
+        del it.end
+        it.start = 0
+            
+    def no_next(self, it):
+        return 0
+
+    def it_end(self, it):
+        if hasattr(it, '_next'):
+            return 0
+        return not self.prep_next(it)
+
+class SeqInner(InnerBase):
+    '''Inner class for sequence Iterators'''
+
+    def _supports(self, ob):
+        try: ob[0]
+        except (TypeError, AttributeError): return 0
+        except: pass
+        return 1
+
+    def prep_next(self, it):
+        i = it.nextIndex
+        try:
+            it._next = it.seq[i]
+        except IndexError:
+            it._prep_next = self.no_next
+            it.end = 1
+            return 0
+        it.end = 0
+        return 1
+
+    def it_length(self, it):
+        it.length = l = len(it.seq)
+        return l
+
+try:
+    StopIteration=StopIteration
+except NameError:
+    StopIteration="StopIteration"
+
+class IterInner(InnerBase):
+    '''Iterator inner class for Python iterators'''
+
+    def _supports(self, ob):
+        try:
+            if hasattr(ob, 'next') and (ob is iter(ob)):
+                return 1
+        except:
+            return 0
+
+    def prep_next(self, it):
+        try:
+            it._next = it.seq.next()
+        except StopIteration:
+            it._prep_next = self.no_next
+            it.end = 1
+            return 0
+        it.end = 0
+        return 1
+
+class IterIter:
+    def __init__(self, it):
+        self.it = it
+        self.skip = it.nextIndex > 0 and not it.end
+    def next(self):
+        it = self.it
+        if self.skip:
+            self.skip = 0
+            return it.item
+        if it.next():
+            return it.item
+        raise StopIteration
+
+seqInner = SeqInner()
+iterInner = IterInner()

Added: tracker/vendor/roundup/current/roundup/cgi/ZTUtils/__init__.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/ZTUtils/__init__.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,25 @@
+##############################################################################
+#
+# Copyright (c) 2001 Zope Corporation and Contributors. All Rights Reserved.
+# 
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.0 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE
+# 
+##############################################################################
+__doc__='''Package of template utility classes and functions.
+
+Modified for Roundup 0.5 release:
+
+- removed Zope imports
+
+$Id: __init__.py,v 1.3 2004/02/11 23:55:09 richard Exp $'''
+__docformat__ = 'restructuredtext'
+__version__='$Revision: 1.3 $'[11:-2]
+
+from Batch import Batch
+from Iterator import Iterator
+

Added: tracker/vendor/roundup/current/roundup/cgi/__init__.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/__init__.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,2 @@
+''' CGI interface modules '''
+__docformat__ = 'restructuredtext'

Added: tracker/vendor/roundup/current/roundup/cgi/accept_language.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/accept_language.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,75 @@
+"""Parse the Accept-Language header as defined in RFC2616.
+
+See http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.4
+for details.  This module should follow the spec.
+Author: Hernan M. Foffani (hfoffani at gmail.com)
+Some use samples:
+
+>>> parse("da, en-gb;q=0.8, en;q=0.7")
+['da', 'en_gb', 'en']
+>>> parse("en;q=0.2, fr;q=1")
+['fr', 'en']
+>>> parse("zn; q = 0.2 ,pt-br;q =1")
+['pt_br', 'zn']
+>>> parse("es-AR")
+['es_AR']
+>>> parse("es-es-cat")
+['es_es_cat']
+>>> parse("")
+[]
+>>> parse(None)
+[]
+>>> parse("   ")
+[]
+>>> parse("en,")
+['en']
+"""
+
+import re
+import heapq
+
+# regexp for languange-range search
+nqlre = "([A-Za-z]+[-[A-Za-z]+]*)$"
+# regexp for languange-range search with quality value
+qlre  = "([A-Za-z]+[-[A-Za-z]+]*);q=([\d\.]+)"
+# both
+lre   = re.compile(nqlre + "|" + qlre)
+
+ascii = ''.join([chr(x) for x in xrange(256)])
+whitespace = ' \t\n\r\v\f'
+
+def parse(language_header):
+    """parse(string_with_accept_header_content) -> languages list"""
+
+    if language_header is None: return []
+
+    # strip whitespaces.
+    lh = language_header.translate(ascii, whitespace)
+
+    # if nothing, return
+    if lh == "": return []
+
+    # split by commas and parse the quality values.
+    pls = [lre.findall(x) for x in lh.split(',')]
+
+    # drop uncomformant
+    qls = [x[0] for x in pls if len(x) > 0]
+
+    # use a heap queue to sort by quality values.
+    # the value of each item is 1.0 complement.
+    pq = []
+    for l in qls:
+        if l[0] != '':
+            heapq.heappush(pq, (0.0, l[0]))
+        else:
+            heapq.heappush(pq, (1.0-float(l[2]), l[1]))
+
+    # get the languages ordered by quality
+    # and replace - by _
+    return [x[1].replace('-','_') for x in pq]
+
+if __name__ == "__main__":
+    import doctest
+    doctest.testmod()
+
+# vim: set et sts=4 sw=4 :

Added: tracker/vendor/roundup/current/roundup/cgi/actions.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/actions.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,997 @@
+#$Id: actions.py,v 1.60 2006/04/27 03:44:47 richard Exp $
+
+import re, cgi, StringIO, urllib, Cookie, time, random, csv, codecs
+
+from roundup import hyperdb, token, date, password
+from roundup.i18n import _
+import roundup.exceptions
+from roundup.cgi import exceptions, templating
+from roundup.mailgw import uidFromAddress
+
+__all__ = ['Action', 'ShowAction', 'RetireAction', 'SearchAction',
+           'EditCSVAction', 'EditItemAction', 'PassResetAction',
+           'ConfRegoAction', 'RegisterAction', 'LoginAction', 'LogoutAction',
+           'NewItemAction', 'ExportCSVAction']
+
+# used by a couple of routines
+chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
+
+class Action:
+    def __init__(self, client):
+        self.client = client
+        self.form = client.form
+        self.db = client.db
+        self.nodeid = client.nodeid
+        self.template = client.template
+        self.classname = client.classname
+        self.userid = client.userid
+        self.base = client.base
+        self.user = client.user
+        self.context = templating.context(client)
+
+    def handle(self):
+        """Action handler procedure"""
+        raise NotImplementedError
+
+    def execute(self):
+        """Execute the action specified by this object."""
+        self.permission()
+        return self.handle()
+
+    name = ''
+    permissionType = None
+    def permission(self):
+        """Check whether the user has permission to execute this action.
+
+        True by default. If the permissionType attribute is a string containing
+        a simple permission, check whether the user has that permission.
+        Subclasses must also define the name attribute if they define
+        permissionType.
+
+        Despite having this permission, users may still be unauthorised to
+        perform parts of actions. It is up to the subclasses to detect this.
+        """
+        if (self.permissionType and
+                not self.hasPermission(self.permissionType)):
+            info = {'action': self.name, 'classname': self.classname}
+            raise exceptions.Unauthorised, self._(
+                'You do not have permission to '
+                '%(action)s the %(classname)s class.')%info
+
+    _marker = []
+    def hasPermission(self, permission, classname=_marker, itemid=None):
+        """Check whether the user has 'permission' on the current class."""
+        if classname is self._marker:
+            classname = self.client.classname
+        return self.db.security.hasPermission(permission, self.client.userid,
+            classname=classname, itemid=itemid)
+
+    def gettext(self, msgid):
+        """Return the localized translation of msgid"""
+        return self.client.translator.gettext(msgid)
+
+    _ = gettext
+
+class ShowAction(Action):
+
+    typere=re.compile('[@:]type')
+    numre=re.compile('[@:]number')
+
+    def handle(self):
+        """Show a node of a particular class/id."""
+        t = n = ''
+        for key in self.form.keys():
+            if self.typere.match(key):
+                t = self.form[key].value.strip()
+            elif self.numre.match(key):
+                n = self.form[key].value.strip()
+        if not t:
+            raise ValueError, self._('No type specified')
+        if not n:
+            raise exceptions.SeriousError, self._('No ID entered')
+        try:
+            int(n)
+        except ValueError:
+            d = {'input': n, 'classname': t}
+            raise exceptions.SeriousError, self._(
+                '"%(input)s" is not an ID (%(classname)s ID required)')%d
+        url = '%s%s%s'%(self.base, t, n)
+        raise exceptions.Redirect, url
+
+class RetireAction(Action):
+    name = 'retire'
+    permissionType = 'Edit'
+
+    def handle(self):
+        """Retire the context item."""
+        # if we want to view the index template now, then unset the nodeid
+        # context info (a special-case for retire actions on the index page)
+        nodeid = self.nodeid
+        if self.template == 'index':
+            self.client.nodeid = None
+
+        # make sure we don't try to retire admin or anonymous
+        if self.classname == 'user' and \
+                self.db.user.get(nodeid, 'username') in ('admin', 'anonymous'):
+            raise ValueError, self._(
+                'You may not retire the admin or anonymous user')
+
+        # do the retire
+        self.db.getclass(self.classname).retire(nodeid)
+        self.db.commit()
+
+        self.client.ok_message.append(
+            self._('%(classname)s %(itemid)s has been retired')%{
+                'classname': self.classname.capitalize(), 'itemid': nodeid})
+
+    def hasPermission(self, permission, classname=Action._marker, itemid=None):
+        if itemid is None:
+            itemid = self.nodeid
+        return Action.hasPermission(self, permission, classname, itemid)
+
+class SearchAction(Action):
+    name = 'search'
+    permissionType = 'View'
+
+    def handle(self):
+        """Mangle some of the form variables.
+
+        Set the form ":filter" variable based on the values of the filter
+        variables - if they're set to anything other than "dontcare" then add
+        them to :filter.
+
+        Handle the ":queryname" variable and save off the query to the user's
+        query list.
+
+        Split any String query values on whitespace and comma.
+
+        """
+        self.fakeFilterVars()
+        queryname = self.getQueryName()
+    
+        # editing existing query name?
+        old_queryname = ''
+        for key in ('@old-queryname', ':old-queryname'):
+            if self.form.has_key(key):
+                old_queryname = self.form[key].value.strip()
+
+        # handle saving the query params
+        if queryname:
+            # parse the environment and figure what the query _is_
+            req = templating.HTMLRequest(self.client)
+
+            # The [1:] strips off the '?' character, it isn't part of the
+            # query string.
+            url = req.indexargs_url('', {})[1:]
+
+            key = self.db.query.getkey()
+            if key:
+                # edit the old way, only one query per name
+                try:
+                    qid = self.db.query.lookup(old_queryname)
+                    if not self.hasPermission('Edit', 'query', itemid=qid):
+                        raise exceptions.Unauthorised, self._(
+                            "You do not have permission to edit queries")
+                    self.db.query.set(qid, klass=self.classname, url=url)
+                except KeyError:
+                    # create a query
+                    if not self.hasPermission('Create', 'query'):
+                        raise exceptions.Unauthorised, self._(
+                            "You do not have permission to store queries")
+                    qid = self.db.query.create(name=queryname,
+                        klass=self.classname, url=url)
+            else:
+                # edit the new way, query name not a key any more
+                # see if we match an existing private query
+                uid = self.db.getuid()
+                qids = self.db.query.filter(None, {'name': old_queryname,
+                        'private_for': uid})
+                if not qids:
+                    # ok, so there's not a private query for the current user
+                    # - see if there's one created by them
+                    qids = self.db.query.filter(None, {'name': old_queryname,
+                        'creator': uid})
+
+                if qids and old_queryname:
+                    # edit query - make sure we get an exact match on the name
+                    for qid in qids:
+                        if old_queryname != self.db.query.get(qid, 'name'):
+                            continue
+                        if not self.hasPermission('Edit', 'query', itemid=qid):
+                            raise exceptions.Unauthorised, self._(
+                            "You do not have permission to edit queries")
+                        self.db.query.set(qid, klass=self.classname,
+                            url=url, name=queryname)
+                else:
+                    # create a query
+                    if not self.hasPermission('Create', 'query'):
+                        raise exceptions.Unauthorised, self._(
+                            "You do not have permission to store queries")
+                    qid = self.db.query.create(name=queryname,
+                        klass=self.classname, url=url, private_for=uid)
+
+            # and add it to the user's query multilink
+            queries = self.db.user.get(self.userid, 'queries')
+            if qid not in queries:
+                queries.append(qid)
+                self.db.user.set(self.userid, queries=queries)
+
+            # commit the query change to the database
+            self.db.commit()
+
+    def fakeFilterVars(self):
+        """Add a faked :filter form variable for each filtering prop."""
+        props = self.db.classes[self.classname].getprops()
+        for key in self.form.keys():
+            if not props.has_key(key):
+                continue
+            if isinstance(self.form[key], type([])):
+                # search for at least one entry which is not empty
+                for minifield in self.form[key]:
+                    if minifield.value:
+                        break
+                else:
+                    continue
+            else:
+                if not self.form[key].value:
+                    continue
+                if isinstance(props[key], hyperdb.String):
+                    v = self.form[key].value
+                    l = token.token_split(v)
+                    if len(l) > 1 or l[0] != v:
+                        self.form.value.remove(self.form[key])
+                        # replace the single value with the split list
+                        for v in l:
+                            self.form.value.append(cgi.MiniFieldStorage(key, v))
+
+            self.form.value.append(cgi.MiniFieldStorage('@filter', key))
+
+    def getQueryName(self):
+        for key in ('@queryname', ':queryname'):
+            if self.form.has_key(key):
+                return self.form[key].value.strip()
+        return ''
+
+class EditCSVAction(Action):
+    name = 'edit'
+    permissionType = 'Edit'
+
+    def handle(self):
+        """Performs an edit of all of a class' items in one go.
+
+        The "rows" CGI var defines the CSV-formatted entries for the class. New
+        nodes are identified by the ID 'X' (or any other non-existent ID) and
+        removed lines are retired.
+
+        """
+        cl = self.db.classes[self.classname]
+        idlessprops = cl.getprops(protected=0).keys()
+        idlessprops.sort()
+        props = ['id'] + idlessprops
+
+        # do the edit
+        rows = StringIO.StringIO(self.form['rows'].value)
+        reader = csv.reader(rows)
+        found = {}
+        line = 0
+        for values in reader:
+            line += 1
+            if line == 1: continue
+            # skip property names header
+            if values == props:
+                continue
+
+            # extract the nodeid
+            nodeid, values = values[0], values[1:]
+            found[nodeid] = 1
+
+            # see if the node exists
+            if nodeid in ('x', 'X') or not cl.hasnode(nodeid):
+                exists = 0
+            else:
+                exists = 1
+
+            # confirm correct weight
+            if len(idlessprops) != len(values):
+                self.client.error_message.append(
+                    self._('Not enough values on line %(line)s')%{'line':line})
+                return
+
+            # extract the new values
+            d = {}
+            for name, value in zip(idlessprops, values):
+                prop = cl.properties[name]
+                value = value.strip()
+                # only add the property if it has a value
+                if value:
+                    # if it's a multilink, split it
+                    if isinstance(prop, hyperdb.Multilink):
+                        value = value.split(':')
+                    elif isinstance(prop, hyperdb.Password):
+                        value = password.Password(value)
+                    elif isinstance(prop, hyperdb.Interval):
+                        value = date.Interval(value)
+                    elif isinstance(prop, hyperdb.Date):
+                        value = date.Date(value)
+                    elif isinstance(prop, hyperdb.Boolean):
+                        value = value.lower() in ('yes', 'true', 'on', '1')
+                    elif isinstance(prop, hyperdb.Number):
+                        value = float(value)
+                    d[name] = value
+                elif exists:
+                    # nuke the existing value
+                    if isinstance(prop, hyperdb.Multilink):
+                        d[name] = []
+                    else:
+                        d[name] = None
+
+            # perform the edit
+            if exists:
+                # edit existing
+                cl.set(nodeid, **d)
+            else:
+                # new node
+                found[cl.create(**d)] = 1
+
+        # retire the removed entries
+        for nodeid in cl.list():
+            if not found.has_key(nodeid):
+                cl.retire(nodeid)
+
+        # all OK
+        self.db.commit()
+
+        self.client.ok_message.append(self._('Items edited OK'))
+
+class EditCommon(Action):
+    '''Utility methods for editing.'''
+
+    def _editnodes(self, all_props, all_links):
+        ''' Use the props in all_props to perform edit and creation, then
+            use the link specs in all_links to do linking.
+        '''
+        # figure dependencies and re-work links
+        deps = {}
+        links = {}
+        for cn, nodeid, propname, vlist in all_links:
+            if not all_props.has_key((cn, nodeid)):
+                # link item to link to doesn't (and won't) exist
+                continue
+            for value in vlist:
+                if not all_props.has_key(value):
+                    # link item to link to doesn't (and won't) exist
+                    continue
+                deps.setdefault((cn, nodeid), []).append(value)
+                links.setdefault(value, []).append((cn, nodeid, propname))
+
+        # figure chained dependencies ordering
+        order = []
+        done = {}
+        # loop detection
+        change = 0
+        while len(all_props) != len(done):
+            for needed in all_props.keys():
+                if done.has_key(needed):
+                    continue
+                tlist = deps.get(needed, [])
+                for target in tlist:
+                    if not done.has_key(target):
+                        break
+                else:
+                    done[needed] = 1
+                    order.append(needed)
+                    change = 1
+            if not change:
+                raise ValueError, 'linking must not loop!'
+
+        # now, edit / create
+        m = []
+        for needed in order:
+            props = all_props[needed]
+            if not props:
+                # nothing to do
+                continue
+            cn, nodeid = needed
+
+            if nodeid is not None and int(nodeid) > 0:
+                # make changes to the node
+                props = self._changenode(cn, nodeid, props)
+
+                # and some nice feedback for the user
+                if props:
+                    info = ', '.join(map(self._, props.keys()))
+                    m.append(
+                        self._('%(class)s %(id)s %(properties)s edited ok')
+                        % {'class':cn, 'id':nodeid, 'properties':info})
+                else:
+                    m.append(self._('%(class)s %(id)s - nothing changed')
+                        % {'class':cn, 'id':nodeid})
+            else:
+                assert props
+
+                # make a new node
+                newid = self._createnode(cn, props)
+                if nodeid is None:
+                    self.nodeid = newid
+                nodeid = newid
+
+                # and some nice feedback for the user
+                m.append(self._('%(class)s %(id)s created')
+                    % {'class':cn, 'id':newid})
+
+            # fill in new ids in links
+            if links.has_key(needed):
+                for linkcn, linkid, linkprop in links[needed]:
+                    props = all_props[(linkcn, linkid)]
+                    cl = self.db.classes[linkcn]
+                    propdef = cl.getprops()[linkprop]
+                    if not props.has_key(linkprop):
+                        if linkid is None or linkid.startswith('-'):
+                            # linking to a new item
+                            if isinstance(propdef, hyperdb.Multilink):
+                                props[linkprop] = [newid]
+                            else:
+                                props[linkprop] = newid
+                        else:
+                            # linking to an existing item
+                            if isinstance(propdef, hyperdb.Multilink):
+                                existing = cl.get(linkid, linkprop)[:]
+                                existing.append(nodeid)
+                                props[linkprop] = existing
+                            else:
+                                props[linkprop] = newid
+
+        return '<br>'.join(m)
+
+    def _changenode(self, cn, nodeid, props):
+        """Change the node based on the contents of the form."""
+        # check for permission
+        if not self.editItemPermission(props, classname=cn, itemid=nodeid):
+            raise exceptions.Unauthorised, self._(
+                'You do not have permission to edit %(class)s'
+            ) % {'class': cn}
+
+        # make the changes
+        cl = self.db.classes[cn]
+        return cl.set(nodeid, **props)
+
+    def _createnode(self, cn, props):
+        """Create a node based on the contents of the form."""
+        # check for permission
+        if not self.newItemPermission(props, classname=cn):
+            raise exceptions.Unauthorised, self._(
+                'You do not have permission to create %(class)s'
+            ) % {'class': cn}
+
+        # create the node and return its id
+        cl = self.db.classes[cn]
+        return cl.create(**props)
+
+    def isEditingSelf(self):
+        """Check whether a user is editing his/her own details."""
+        return (self.nodeid == self.userid
+                and self.db.user.get(self.nodeid, 'username') != 'anonymous')
+
+    _cn_marker = []
+    def editItemPermission(self, props, classname=_cn_marker, itemid=None):
+        """Determine whether the user has permission to edit this item.
+
+        Base behaviour is to check the user can edit this class. If we're
+        editing the "user" class, users are allowed to edit their own details.
+        Unless it's the "roles" property, which requires the special Permission
+        "Web Roles".
+        """
+        if self.classname == 'user':
+            if props.has_key('roles') and not self.hasPermission('Web Roles'):
+                raise exceptions.Unauthorised, self._(
+                    "You do not have permission to edit user roles")
+            if self.isEditingSelf():
+                return 1
+        if itemid is None:
+            itemid = self.nodeid
+        if classname is self._cn_marker:
+            classname = self.classname
+        if self.hasPermission('Edit', itemid=itemid, classname=classname):
+            return 1
+        return 0
+
+    def newItemPermission(self, props, classname=None):
+        """Determine whether the user has permission to create this item.
+
+        Base behaviour is to check the user can edit this class. No additional
+        property checks are made.
+        """
+        if not classname :
+            classname = self.client.classname
+        return self.hasPermission('Create', classname=classname)
+
+class EditItemAction(EditCommon):
+    def lastUserActivity(self):
+        if self.form.has_key(':lastactivity'):
+            d = date.Date(self.form[':lastactivity'].value)
+        elif self.form.has_key('@lastactivity'):
+            d = date.Date(self.form['@lastactivity'].value)
+        else:
+            return None
+        d.second = int(d.second)
+        return d
+
+    def lastNodeActivity(self):
+        cl = getattr(self.client.db, self.classname)
+        activity = cl.get(self.nodeid, 'activity').local(0)
+        activity.second = int(activity.second)
+        return activity
+
+    def detectCollision(self, user_activity, node_activity):
+        '''Check for a collision and return the list of props we edited
+        that conflict.'''
+        if user_activity and user_activity < node_activity:
+            props, links = self.client.parsePropsFromForm()
+            key = (self.classname, self.nodeid)
+            # we really only collide for direct prop edit conflicts
+            return props[key].keys()
+        else:
+            return []
+
+    def handleCollision(self, props):
+        message = self._('Edit Error: someone else has edited this %s (%s). '
+            'View <a target="new" href="%s%s">their changes</a> '
+            'in a new window.')%(self.classname, ', '.join(props),
+            self.classname, self.nodeid)
+        self.client.error_message.append(message)
+        return
+
+    def handle(self):
+        """Perform an edit of an item in the database.
+
+        See parsePropsFromForm and _editnodes for special variables.
+
+        """
+        user_activity = self.lastUserActivity()
+        if user_activity:
+            props = self.detectCollision(user_activity, self.lastNodeActivity())
+            if props:
+                self.handleCollision(props)
+                return
+
+        props, links = self.client.parsePropsFromForm()
+
+        # handle the props
+        try:
+            message = self._editnodes(props, links)
+        except (ValueError, KeyError, IndexError,
+                roundup.exceptions.Reject), message:
+            self.client.error_message.append(
+                self._('Edit Error: %s') % str(message))
+            return
+
+        # commit now that all the tricky stuff is done
+        self.db.commit()
+
+        # redirect to the item's edit page
+        # redirect to finish off
+        url = self.base + self.classname
+        # note that this action might have been called by an index page, so
+        # we will want to include index-page args in this URL too
+        if self.nodeid is not None:
+            url += self.nodeid
+        url += '?@ok_message=%s&@template=%s'%(urllib.quote(message),
+            urllib.quote(self.template))
+        if self.nodeid is None:
+            req = templating.HTMLRequest(self.client)
+            url += '&' + req.indexargs_url('', {})[1:]
+        raise exceptions.Redirect, url
+
+class NewItemAction(EditCommon):
+    def handle(self):
+        ''' Add a new item to the database.
+
+            This follows the same form as the EditItemAction, with the same
+            special form values.
+        '''
+        # parse the props from the form
+        try:
+            props, links = self.client.parsePropsFromForm(create=1)
+        except (ValueError, KeyError), message:
+            self.client.error_message.append(self._('Error: %s')
+                % str(message))
+            return
+
+        # handle the props - edit or create
+        try:
+            # when it hits the None element, it'll set self.nodeid
+            messages = self._editnodes(props, links)
+        except (ValueError, KeyError, IndexError,
+                roundup.exceptions.Reject), message:
+            # these errors might just be indicative of user dumbness
+            self.client.error_message.append(_('Error: %s') % str(message))
+            return
+
+        # commit now that all the tricky stuff is done
+        self.db.commit()
+
+        # redirect to the new item's page
+        raise exceptions.Redirect, '%s%s%s?@ok_message=%s&@template=%s' % (
+            self.base, self.classname, self.nodeid, urllib.quote(messages),
+            urllib.quote(self.template))
+
+class PassResetAction(Action):
+    def handle(self):
+        """Handle password reset requests.
+
+        Presence of either "name" or "address" generates email. Presence of
+        "otk" performs the reset.
+
+        """
+        otks = self.db.getOTKManager()
+        if self.form.has_key('otk'):
+            # pull the rego information out of the otk database
+            otk = self.form['otk'].value
+            uid = otks.get(otk, 'uid')
+            if uid is None:
+                self.client.error_message.append(
+                    self._("Invalid One Time Key!\n"
+                        "(a Mozilla bug may cause this message "
+                        "to show up erroneously, please check your email)"))
+                return
+
+            # re-open the database as "admin"
+            if self.user != 'admin':
+                self.client.opendb('admin')
+                self.db = self.client.db
+                otks = self.db.getOTKManager()
+
+            # change the password
+            newpw = password.generatePassword()
+
+            cl = self.db.user
+            # XXX we need to make the "default" page be able to display errors!
+            try:
+                # set the password
+                cl.set(uid, password=password.Password(newpw))
+                # clear the props from the otk database
+                otks.destroy(otk)
+                self.db.commit()
+            except (ValueError, KeyError), message:
+                self.client.error_message.append(str(message))
+                return
+
+            # user info
+            address = self.db.user.get(uid, 'address')
+            name = self.db.user.get(uid, 'username')
+
+            # send the email
+            tracker_name = self.db.config.TRACKER_NAME
+            subject = 'Password reset for %s'%tracker_name
+            body = '''
+The password has been reset for username "%(name)s".
+
+Your password is now: %(password)s
+'''%{'name': name, 'password': newpw}
+            if not self.client.standard_message([address], subject, body):
+                return
+
+            self.client.ok_message.append(
+                self._('Password reset and email sent to %s') % address)
+            return
+
+        # no OTK, so now figure the user
+        if self.form.has_key('username'):
+            name = self.form['username'].value
+            try:
+                uid = self.db.user.lookup(name)
+            except KeyError:
+                self.client.error_message.append(self._('Unknown username'))
+                return
+            address = self.db.user.get(uid, 'address')
+        elif self.form.has_key('address'):
+            address = self.form['address'].value
+            uid = uidFromAddress(self.db, ('', address), create=0)
+            if not uid:
+                self.client.error_message.append(
+                    self._('Unknown email address'))
+                return
+            name = self.db.user.get(uid, 'username')
+        else:
+            self.client.error_message.append(
+                self._('You need to specify a username or address'))
+            return
+
+        # generate the one-time-key and store the props for later
+        otk = ''.join([random.choice(chars) for x in range(32)])
+        while otks.exists(otk):
+            otk = ''.join([random.choice(chars) for x in range(32)])
+        otks.set(otk, uid=uid)
+        self.db.commit()
+
+        # send the email
+        tracker_name = self.db.config.TRACKER_NAME
+        subject = 'Confirm reset of password for %s'%tracker_name
+        body = '''
+Someone, perhaps you, has requested that the password be changed for your
+username, "%(name)s". If you wish to proceed with the change, please follow
+the link below:
+
+  %(url)suser?@template=forgotten&@action=passrst&otk=%(otk)s
+
+You should then receive another email with the new password.
+'''%{'name': name, 'tracker': tracker_name, 'url': self.base, 'otk': otk}
+        if not self.client.standard_message([address], subject, body):
+            return
+
+        self.client.ok_message.append(self._('Email sent to %s') % address)
+
+class RegoCommon(Action):
+    def finishRego(self):
+        # log the new user in
+        self.client.userid = self.userid
+        user = self.client.user = self.db.user.get(self.userid, 'username')
+        # re-open the database for real, using the user
+        self.client.opendb(user)
+
+        # if we have a session, update it
+        if hasattr(self.client, 'session'):
+            self.client.db.getSessionManager().set(self.client.session,
+                user=user, last_use=time.time())
+        else:
+            # new session cookie
+            self.client.set_cookie(user, expire=None)
+
+        # nice message
+        message = self._('You are now registered, welcome!')
+        url = '%suser%s?@ok_message=%s'%(self.base, self.userid,
+            urllib.quote(message))
+
+        # redirect to the user's page (but not 302, as some email clients seem
+        # to want to reload the page, or something)
+        return '''<html><head><title>%s</title></head>
+            <body><p><a href="%s">%s</a></p>
+            <script type="text/javascript">
+            window.setTimeout('window.location = "%s"', 1000);
+            </script>'''%(message, url, message, url)
+
+class ConfRegoAction(RegoCommon):
+    def handle(self):
+        """Grab the OTK, use it to load up the new user details."""
+        try:
+            # pull the rego information out of the otk database
+            self.userid = self.db.confirm_registration(self.form['otk'].value)
+        except (ValueError, KeyError), message:
+            self.client.error_message.append(str(message))
+            return
+        self.finishRego()
+
+class RegisterAction(RegoCommon, EditCommon):
+    name = 'register'
+    permissionType = 'Create'
+
+    def handle(self):
+        """Attempt to create a new user based on the contents of the form
+        and then set the cookie.
+
+        Return 1 on successful login.
+        """
+        # parse the props from the form
+        try:
+            props, links = self.client.parsePropsFromForm(create=1)
+        except (ValueError, KeyError), message:
+            self.client.error_message.append(self._('Error: %s')
+                % str(message))
+            return
+
+        # registration isn't allowed to supply roles
+        user_props = props[('user', None)]
+        if user_props.has_key('roles'):
+            raise exceptions.Unauthorised, self._(
+                "It is not permitted to supply roles at registration.")
+
+        # skip the confirmation step?
+        if self.db.config['INSTANT_REGISTRATION']:
+            # handle the create now
+            try:
+                # when it hits the None element, it'll set self.nodeid
+                messages = self._editnodes(props, links)
+            except (ValueError, KeyError, IndexError,
+                    roundup.exceptions.Reject), message:
+                # these errors might just be indicative of user dumbness
+                self.client.error_message.append(_('Error: %s') % str(message))
+                return
+
+            # fix up the initial roles
+            self.db.user.set(self.nodeid,
+                roles=self.db.config['NEW_WEB_USER_ROLES'])
+
+            # commit now that all the tricky stuff is done
+            self.db.commit()
+
+            # finish off by logging the user in
+            self.userid = self.nodeid
+            return self.finishRego()
+
+        # generate the one-time-key and store the props for later
+        for propname, proptype in self.db.user.getprops().items():
+            value = user_props.get(propname, None)
+            if value is None:
+                pass
+            elif isinstance(proptype, hyperdb.Date):
+                user_props[propname] = str(value)
+            elif isinstance(proptype, hyperdb.Interval):
+                user_props[propname] = str(value)
+            elif isinstance(proptype, hyperdb.Password):
+                user_props[propname] = str(value)
+        otks = self.db.getOTKManager()
+        otk = ''.join([random.choice(chars) for x in range(32)])
+        while otks.exists(otk):
+            otk = ''.join([random.choice(chars) for x in range(32)])
+        otks.set(otk, **user_props)
+
+        # send the email
+        tracker_name = self.db.config.TRACKER_NAME
+        tracker_email = self.db.config.TRACKER_EMAIL
+        if self.db.config['EMAIL_REGISTRATION_CONFIRMATION']:
+            subject = 'Complete your registration to %s -- key %s'%(tracker_name,
+                                                                  otk)
+            body = """To complete your registration of the user "%(name)s" with
+%(tracker)s, please do one of the following:
+
+- send a reply to %(tracker_email)s and maintain the subject line as is (the
+reply's additional "Re:" is ok),
+
+- or visit the following URL:
+
+%(url)s?@action=confrego&otk=%(otk)s
+
+""" % {'name': user_props['username'], 'tracker': tracker_name,
+        'url': self.base, 'otk': otk, 'tracker_email': tracker_email}
+        else:
+            subject = 'Complete your registration to %s'%(tracker_name)
+            body = """To complete your registration of the user "%(name)s" with
+%(tracker)s, please visit the following URL:
+
+%(url)s?@action=confrego&otk=%(otk)s
+
+""" % {'name': user_props['username'], 'tracker': tracker_name,
+        'url': self.base, 'otk': otk}
+        if not self.client.standard_message([user_props['address']], subject,
+                body, (tracker_name, tracker_email)):
+            return
+
+        # commit changes to the database
+        self.db.commit()
+
+        # redirect to the "you're almost there" page
+        raise exceptions.Redirect, '%suser?@template=rego_progress'%self.base
+
+class LogoutAction(Action):
+    def handle(self):
+        """Make us really anonymous - nuke the cookie too."""
+        # log us out
+        self.client.make_user_anonymous()
+
+        # construct the logout cookie
+        now = Cookie._getdate()
+        self.client.additional_headers['Set-Cookie'] = \
+           '%s=deleted; Max-Age=0; expires=%s; Path=%s;' % (
+               self.client.cookie_name, now, self.client.cookie_path)
+
+        # Let the user know what's going on
+        self.client.ok_message.append(self._('You are logged out'))
+
+        # reset client context to render tracker home page
+        # instead of last viewed page (may be inaccessibe for anonymous)
+        self.client.classname = None
+        self.client.nodeid = None
+        self.client.template = None
+
+class LoginAction(Action):
+    def handle(self):
+        """Attempt to log a user in.
+
+        Sets up a session for the user which contains the login credentials.
+
+        """
+        # we need the username at a minimum
+        if not self.form.has_key('__login_name'):
+            self.client.error_message.append(self._('Username required'))
+            return
+
+        # get the login info
+        self.client.user = self.form['__login_name'].value
+        if self.form.has_key('__login_password'):
+            password = self.form['__login_password'].value
+        else:
+            password = ''
+
+        try:
+            self.verifyLogin(self.client.user, password)
+        except exceptions.LoginError, err:
+            self.client.make_user_anonymous()
+            self.client.error_message.extend(list(err.args))
+            return
+
+        # now we're OK, re-open the database for real, using the user
+        self.client.opendb(self.client.user)
+
+        # set the session cookie
+        if self.form.has_key('remember'):
+            self.client.set_cookie(self.client.user, expire=86400*365)
+        else:
+            self.client.set_cookie(self.client.user, expire=None)
+
+        # If we came from someplace, go back there
+        if self.form.has_key('__came_from'):
+            raise exceptions.Redirect, self.form['__came_from'].value
+
+    def verifyLogin(self, username, password):
+        # make sure the user exists
+        try:
+            self.client.userid = self.db.user.lookup(username)
+        except KeyError:
+            raise exceptions.LoginError, self._('Invalid login')
+
+        # verify the password
+        if not self.verifyPassword(self.client.userid, password):
+            raise exceptions.LoginError, self._('Invalid login')
+
+        # Determine whether the user has permission to log in.
+        # Base behaviour is to check the user has "Web Access".
+        if not self.hasPermission("Web Access"):
+            raise exceptions.LoginError, self._(
+                "You do not have permission to login")
+
+    def verifyPassword(self, userid, password):
+        '''Verify the password that the user has supplied'''
+        stored = self.db.user.get(userid, 'password')
+        if password == stored:
+            return 1
+        if not password and not stored:
+            return 1
+        return 0
+
+class ExportCSVAction(Action):
+    name = 'export'
+    permissionType = 'View'
+
+    def handle(self):
+        ''' Export the specified search query as CSV. '''
+        # figure the request
+        request = templating.HTMLRequest(self.client)
+        filterspec = request.filterspec
+        sort = request.sort
+        group = request.group
+        columns = request.columns
+        klass = self.db.getclass(request.classname)
+
+        # full-text search
+        if request.search_text:
+            matches = self.db.indexer.search(
+                re.findall(r'\b\w{2,25}\b', request.search_text), klass)
+        else:
+            matches = None
+
+        h = self.client.additional_headers
+        h['Content-Type'] = 'text/csv; charset=%s' % self.client.charset
+        # some browsers will honor the filename here...
+        h['Content-Disposition'] = 'inline; filename=query.csv'
+
+        self.client.header()
+
+        if self.client.env['REQUEST_METHOD'] == 'HEAD':
+            # all done, return a dummy string
+            return 'dummy'
+
+        wfile = self.client.request.wfile
+        if self.client.charset != self.client.STORAGE_CHARSET:
+            wfile = codecs.EncodedFile(wfile,
+                self.client.STORAGE_CHARSET, self.client.charset, 'replace')
+
+        writer = csv.writer(wfile)
+        writer.writerow(columns)
+
+        # and search
+        for itemid in klass.filter(matches, filterspec, sort, group):
+            writer.writerow([str(klass.get(itemid, col)) for col in columns])
+
+        return '\n'
+
+# vim: set filetype=python sts=4 sw=4 et si :

Added: tracker/vendor/roundup/current/roundup/cgi/apache.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/apache.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,108 @@
+# mod_python interface for Roundup Issue Tracker
+#
+# This module is free software, you may redistribute it
+# and/or modify under the same terms as Python.
+#
+# This module provides Roundup Web User Interface
+# using mod_python Apache module.  Initially written
+# with python 2.3.3, mod_python 3.1.3, roundup 0.7.0.
+#
+# This module operates with only one tracker
+# and must be placed in the tracker directory.
+#
+# History (most recent first):
+# 11-jul-2004 [als] added 'TrackerLanguage' option;
+#                   pass message translator to the tracker client instance
+# 04-jul-2004 [als] tracker lookup moved from module global to request handler;
+#                   use PythonOption TrackerHome (configured in apache)
+#                   to open the tracker
+# 06-may-2004 [als] use cgi.FieldStorage from Python library
+#                   instead of mod_python FieldStorage
+# 29-apr-2004 [als] created
+
+__version__ = "$Revision: 1.4 $"[11:-2]
+__date__ = "$Date: 2004/11/22 07:33:34 $"[7:-2]
+
+import cgi
+import os
+
+from mod_python import apache
+
+import roundup.instance
+from roundup.cgi import TranslationService
+
+class Headers(dict):
+
+    """HTTP headers wrapper"""
+
+    def __init__(self, headers):
+        """Initialize with `apache.table`"""
+        super(Headers, self).__init__(headers)
+        self.getheader = self.get
+
+class Request(object):
+
+    """`apache.Request` object wrapper providing roundup client interface"""
+
+    def __init__(self, request):
+        """Initialize with `apache.Request` object"""
+        self._req = request
+        # .headers.getheader()
+        self.headers = Headers(request.headers_in)
+        # .wfile.write()
+        self.wfile = self._req
+
+    def send_response(self, response_code):
+        """Set HTTP response code"""
+        self._req.status = response_code
+
+    def send_header(self, name, value):
+        """Set output header"""
+        # value may be an instance of roundup.cgi.exceptions.HTTPException
+        value = str(value)
+        # XXX default content_type is "text/plain",
+        #   and ain't overrided by "Content-Type" header
+        if name == "Content-Type":
+            self._req.content_type = value
+        else:
+            self._req.headers_out.add(name, value)
+
+    def end_headers(self):
+        """NOOP. There aint no such thing as 'end_headers' in mod_python"""
+        pass
+
+def handler(req):
+    """HTTP request handler"""
+    _options = req.get_options()
+    _home = _options.get("TrackerHome")
+    _lang = _options.get("TrackerLanguage")
+    _timing = _options.get("TrackerTiming", "no")
+    if _timing.lower() in ("no", "false"):
+        _timing = ""
+    _debug = _options.get("TrackerDebug", "no")
+    _debug = _debug.lower not in ("no", "false")
+    if not (_home and os.path.isdir(_home)):
+        apache.log_error(
+            "PythonOption TrackerHome missing or invalid for %(uri)s"
+            % {'uri': req.uri})
+        return apache.HTTP_INTERNAL_SERVER_ERROR
+    _tracker = roundup.instance.open(_home, not _debug)
+    # create environment
+    # Note: cookies are read from HTTP variables, so we need all HTTP vars
+    req.add_common_vars()
+    _env = dict(req.subprocess_env)
+    # XXX classname must be the first item in PATH_INFO.  roundup.cgi does:
+    #       path = string.split(os.environ.get('PATH_INFO', '/'), '/')
+    #       os.environ['PATH_INFO'] = string.join(path[2:], '/')
+    #   we just remove the first character ('/')
+    _env["PATH_INFO"] = req.path_info[1:]
+    if _timing:
+        _env["CGI_SHOW_TIMING"] = _timing
+    _form = cgi.FieldStorage(req, environ=_env)
+    _client = _tracker.Client(_tracker, Request(req), _env, _form,
+        translator=TranslationService.get_translation(_lang,
+            tracker_home=_home))
+    _client.main()
+    return apache.OK
+
+# vim: set et sts=4 sw=4 :

Added: tracker/vendor/roundup/current/roundup/cgi/cgitb.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/cgitb.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,219 @@
+#
+# This module was written by Ka-Ping Yee, <ping at lfw.org>.
+#
+# $Id: cgitb.py,v 1.12 2004/07/13 10:18:00 a1s Exp $
+
+"""Extended CGI traceback handler by Ka-Ping Yee, <ping at lfw.org>.
+"""
+__docformat__ = 'restructuredtext'
+
+import sys, os, types, string, keyword, linecache, tokenize, inspect, cgi
+import pydoc, traceback
+
+from roundup.cgi import templating, TranslationService
+
+def get_translator(i18n=None):
+    """Return message translation function (gettext)
+
+    Parameters:
+        i18n - translation service, such as roundup.i18n module
+            or TranslationService object.
+
+    Return ``gettext`` attribute of the ``i18n`` object, if available
+    (must be a message translation function with one argument).
+    If ``gettext`` cannot be obtained from ``i18n``, take default
+    TranslationService.
+
+    """
+    try:
+        return i18n.gettext
+    except:
+        return TranslationService.get_translation().gettext
+
+def breaker():
+    return ('<body bgcolor="white">' +
+            '<font color="white" size="-5"> > </font> ' +
+            '</table>' * 5)
+
+def niceDict(indent, dict):
+    l = []
+    for k,v in dict.items():
+        l.append('<tr><td><strong>%s</strong></td><td>%s</td></tr>'%(k,
+            cgi.escape(repr(v))))
+    return '\n'.join(l)
+
+def pt_html(context=5, i18n=None):
+    _ = get_translator(i18n)
+    esc = cgi.escape
+    exc_info = [esc(str(value)) for value in sys.exc_info()[:2]]
+    l = [_('<h1>Templating Error</h1>\n'
+            '<p><b>%(exc_type)s</b>: %(exc_value)s</p>\n'
+            '<p class="help">Debugging information follows</p>'
+         ) % {'exc_type': exc_info[0], 'exc_value': exc_info[1]},
+         '<ol>',]
+    from roundup.cgi.PageTemplates.Expressions import TraversalError
+    t = inspect.trace(context)
+    t.reverse()
+    for frame, file, lnum, func, lines, index in t:
+        args, varargs, varkw, locals = inspect.getargvalues(frame)
+        if locals.has_key('__traceback_info__'):
+            ti = locals['__traceback_info__']
+            if isinstance(ti, TraversalError):
+                s = []
+                for name, info in ti.path:
+                    s.append(_('<li>"%(name)s" (%(info)s)</li>')
+                        % {'name': name, 'info': esc(repr(info))})
+                s = '\n'.join(s)
+                l.append(_('<li>Looking for "%(name)s", '
+                    'current path:<ol>%(path)s</ol></li>'
+                ) % {'name': ti.name, 'path': s})
+            else:
+                l.append(_('<li>In %s</li>') % esc(str(ti)))
+        if locals.has_key('__traceback_supplement__'):
+            ts = locals['__traceback_supplement__']
+            if len(ts) == 2:
+                supp, context = ts
+                s = _('A problem occurred in your template "%s".') \
+                    % str(context.id)
+                if context._v_errors:
+                    s = s + '<br>' + '<br>'.join(
+                        [esc(x) for x in context._v_errors])
+                l.append('<li>%s</li>'%s)
+            elif len(ts) == 3:
+                supp, context, info = ts
+                l.append(_('''
+<li>While evaluating the %(info)r expression on line %(line)d
+<table class="otherinfo" style="font-size: 90%%">
+ <tr><th colspan="2" class="header">Current variables:</th></tr>
+ %(globals)s
+ %(locals)s
+</table></li>
+''') % {
+    'info': info,
+    'line': context.position[0],
+    'globals': niceDict('    ', context.global_vars),
+    'locals': niceDict('    ', context.local_vars)
+})
+
+    l.append('''
+</ol>
+<table style="font-size: 80%%; color: gray">
+ <tr><th class="header" align="left">%s</th></tr>
+ <tr><td><pre>%s</pre></td></tr>
+</table>''' % (_('Full traceback:'), cgi.escape(''.join(
+        traceback.format_exception(*sys.exc_info())
+    ))))
+    l.append('<p>&nbsp;</p>')
+    return '\n'.join(l)
+
+def html(context=5, i18n=None):
+    _ = get_translator(i18n)
+    etype, evalue = sys.exc_type, sys.exc_value
+    if type(etype) is types.ClassType:
+        etype = etype.__name__
+    pyver = 'Python ' + string.split(sys.version)[0] + '<br>' + sys.executable
+    head = pydoc.html.heading(
+        _('<font size=+1><strong>%(exc_type)s</strong>: %(exc_value)s</font>')
+        % {'exc_type': etype, 'exc_value': evalue},
+        '#ffffff', '#777777', pyver)
+
+    head = head + (_('<p>A problem occurred while running a Python script. '
+                   'Here is the sequence of function calls leading up to '
+                   'the error, with the most recent (innermost) call first. '
+                   'The exception attributes are:'))
+
+    indent = '<tt><small>%s</small>&nbsp;</tt>' % ('&nbsp;' * 5)
+    traceback = []
+    for frame, file, lnum, func, lines, index in inspect.trace(context):
+        if file is None:
+            link = _("&lt;file is None - probably inside <tt>eval</tt> "
+                "or <tt>exec</tt>&gt;")
+        else:
+            file = os.path.abspath(file)
+            link = '<a href="file:%s">%s</a>' % (file, pydoc.html.escape(file))
+        args, varargs, varkw, locals = inspect.getargvalues(frame)
+        if func == '?':
+            call = ''
+        else:
+            call = _('in <strong>%s</strong>') % func + inspect.formatargvalues(
+                    args, varargs, varkw, locals,
+                    formatvalue=lambda value: '=' + pydoc.html.repr(value))
+
+        level = '''
+<table width="100%%" bgcolor="#dddddd" cellspacing=0 cellpadding=2 border=0>
+<tr><td>%s %s</td></tr></table>''' % (link, call)
+
+        if index is None or file is None:
+            traceback.append('<p>' + level)
+            continue
+
+        # do a file inspection
+        names = []
+        def tokeneater(type, token, start, end, line, names=names):
+            if type == tokenize.NAME and token not in keyword.kwlist:
+                if token not in names:
+                    names.append(token)
+            if type == tokenize.NEWLINE: raise IndexError
+        def linereader(file=file, lnum=[lnum]):
+            line = linecache.getline(file, lnum[0])
+            lnum[0] = lnum[0] + 1
+            return line
+
+        try:
+            tokenize.tokenize(linereader, tokeneater)
+        except IndexError:
+            pass
+        lvals = []
+        for name in names:
+            if name in frame.f_code.co_varnames:
+                if locals.has_key(name):
+                    value = pydoc.html.repr(locals[name])
+                else:
+                    value = _('<em>undefined</em>')
+                name = '<strong>%s</strong>' % name
+            else:
+                if frame.f_globals.has_key(name):
+                    value = pydoc.html.repr(frame.f_globals[name])
+                else:
+                    value = _('<em>undefined</em>')
+                name = '<em>global</em> <strong>%s</strong>' % name
+            lvals.append('%s&nbsp;= %s'%(name, value))
+        if lvals:
+            lvals = string.join(lvals, ', ')
+            lvals = indent + '<small><font color="#909090">%s'\
+                '</font></small><br>'%lvals
+        else:
+            lvals = ''
+
+        excerpt = []
+        i = lnum - index
+        for line in lines:
+            number = '&nbsp;' * (5-len(str(i))) + str(i)
+            number = '<small><font color="#909090">%s</font></small>' % number
+            line = '<tt>%s&nbsp;%s</tt>' % (number, pydoc.html.preformat(line))
+            if i == lnum:
+                line = '''
+<table width="100%%" bgcolor="white" cellspacing=0 cellpadding=0 border=0>
+<tr><td>%s</td></tr></table>''' % line
+            excerpt.append('\n' + line)
+            if i == lnum:
+                excerpt.append(lvals)
+            i = i + 1
+        traceback.append('<p>' + level + string.join(excerpt, '\n'))
+
+    traceback.reverse()
+
+    exception = '<p><strong>%s</strong>: %s' % (str(etype), str(evalue))
+    attribs = []
+    if type(evalue) is types.InstanceType:
+        for name in dir(evalue):
+            value = pydoc.html.repr(getattr(evalue, name))
+            attribs.append('<br>%s%s&nbsp;= %s' % (indent, name, value))
+
+    return head + string.join(attribs) + string.join(traceback) + '<p>&nbsp;</p>'
+
+def handler():
+    print breaker()
+    print html()
+
+# vim: set filetype=python ts=4 sw=4 et si :

Added: tracker/vendor/roundup/current/roundup/cgi/client.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/client.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,959 @@
+# $Id: client.py,v 1.225 2006/04/27 04:03:11 richard Exp $
+
+"""WWW request handler (also used in the stand-alone server).
+"""
+__docformat__ = 'restructuredtext'
+
+import base64, binascii, cgi, codecs, mimetypes, os
+import random, re, rfc822, stat, time, urllib, urlparse
+import Cookie
+
+from roundup import roundupdb, date, hyperdb, password
+from roundup.cgi import templating, cgitb, TranslationService
+from roundup.cgi.actions import *
+from roundup.exceptions import *
+from roundup.cgi.exceptions import *
+from roundup.cgi.form_parser import FormParser
+from roundup.mailer import Mailer, MessageSendError
+from roundup.cgi import accept_language
+
+def initialiseSecurity(security):
+    '''Create some Permissions and Roles on the security object
+
+    This function is directly invoked by security.Security.__init__()
+    as a part of the Security object instantiation.
+    '''
+    p = security.addPermission(name="Web Access",
+        description="User may access the web interface")
+    security.addPermissionToRole('Admin', p)
+
+    # doing Role stuff through the web - make sure Admin can
+    # TODO: deprecate this and use a property-based control
+    p = security.addPermission(name="Web Roles",
+        description="User may manipulate user Roles through the web")
+    security.addPermissionToRole('Admin', p)
+
+# used to clean messages passed through CGI variables - HTML-escape any tag
+# that isn't <a href="">, <i>, <b> and <br> (including XHTML variants) so
+# that people can't pass through nasties like <script>, <iframe>, ...
+CLEAN_MESSAGE_RE = r'(<(/?(.*?)(\s*href="[^"]")?\s*/?)>)'
+def clean_message(message, mc=re.compile(CLEAN_MESSAGE_RE, re.I)):
+    return mc.sub(clean_message_callback, message)
+def clean_message_callback(match, ok={'a':1,'i':1,'b':1,'br':1}):
+    ''' Strip all non <a>,<i>,<b> and <br> tags from a string
+    '''
+    if ok.has_key(match.group(3).lower()):
+        return match.group(1)
+    return '&lt;%s&gt;'%match.group(2)
+
+error_message = ""'''<html><head><title>An error has occurred</title></head>
+<body><h1>An error has occurred</h1>
+<p>A problem was encountered processing your request.
+The tracker maintainers have been notified of the problem.</p>
+</body></html>'''
+
+class Client:
+    '''Instantiate to handle one CGI request.
+
+    See inner_main for request processing.
+
+    Client attributes at instantiation:
+
+    - "path" is the PATH_INFO inside the instance (with no leading '/')
+    - "base" is the base URL for the instance
+    - "form" is the cgi form, an instance of FieldStorage from the standard
+      cgi module
+    - "additional_headers" is a dictionary of additional HTTP headers that
+      should be sent to the client
+    - "response_code" is the HTTP response code to send to the client
+    - "translator" is TranslationService instance
+
+    During the processing of a request, the following attributes are used:
+
+    - "error_message" holds a list of error messages
+    - "ok_message" holds a list of OK messages
+    - "session" is the current user session id
+    - "user" is the current user's name
+    - "userid" is the current user's id
+    - "template" is the current :template context
+    - "classname" is the current class context name
+    - "nodeid" is the current context item id
+
+    User Identification:
+     If the user has no login cookie, then they are anonymous and are logged
+     in as that user. This typically gives them all Permissions assigned to the
+     Anonymous Role.
+
+     Once a user logs in, they are assigned a session. The Client instance
+     keeps the nodeid of the session as the "session" attribute.
+
+    Special form variables:
+     Note that in various places throughout this code, special form
+     variables of the form :<name> are used. The colon (":") part may
+     actually be one of either ":" or "@".
+    '''
+
+    # charset used for data storage and form templates
+    # Note: must be in lower case for comparisons!
+    # XXX take this from instance.config?
+    STORAGE_CHARSET = 'utf-8'
+
+    #
+    # special form variables
+    #
+    FV_TEMPLATE = re.compile(r'[@:]template')
+    FV_OK_MESSAGE = re.compile(r'[@:]ok_message')
+    FV_ERROR_MESSAGE = re.compile(r'[@:]error_message')
+
+    # Note: index page stuff doesn't appear here:
+    # columns, sort, sortdir, filter, group, groupdir, search_text,
+    # pagesize, startwith
+
+    def __init__(self, instance, request, env, form=None, translator=None):
+        # re-seed the random number generator
+        random.seed()
+        self.start = time.time()
+        self.instance = instance
+        self.request = request
+        self.env = env
+        self.setTranslator(translator)
+        self.mailer = Mailer(instance.config)
+
+        # save off the path
+        self.path = env['PATH_INFO']
+
+        # this is the base URL for this tracker
+        self.base = self.instance.config.TRACKER_WEB
+
+        # check the tracker_we setting
+        if not self.base.endswith('/'):
+            self.base = self.base + '/'
+
+        # this is the "cookie path" for this tracker (ie. the path part of
+        # the "base" url)
+        self.cookie_path = urlparse.urlparse(self.base)[2]
+        self.cookie_name = 'roundup_session_' + re.sub('[^a-zA-Z]', '',
+            self.instance.config.TRACKER_NAME)
+        # cookies to set in http responce
+        # {(path, name): (value, expire)}
+        self.add_cookies = {}
+
+        # see if we need to re-parse the environment for the form (eg Zope)
+        if form is None:
+            self.form = cgi.FieldStorage(environ=env)
+        else:
+            self.form = form
+
+        # turn debugging on/off
+        try:
+            self.debug = int(env.get("ROUNDUP_DEBUG", 0))
+        except ValueError:
+            # someone gave us a non-int debug level, turn it off
+            self.debug = 0
+
+        # flag to indicate that the HTTP headers have been sent
+        self.headers_done = 0
+
+        # additional headers to send with the request - must be registered
+        # before the first write
+        self.additional_headers = {}
+        self.response_code = 200
+
+        # default character set
+        self.charset = self.STORAGE_CHARSET
+
+        # parse cookies (used in charset and session lookups)
+        self.cookie = Cookie.SimpleCookie(self.env.get('HTTP_COOKIE', ''))
+
+        self.user = None
+        self.userid = None
+        self.nodeid = None
+        self.classname = None
+        self.template = None
+
+    def setTranslator(self, translator=None):
+        """Replace the translation engine
+
+        'translator'
+           is TranslationService instance.
+           It must define methods 'translate' (TAL-compatible i18n),
+           'gettext' and 'ngettext' (gettext-compatible i18n).
+
+           If omitted, create default TranslationService.
+        """
+        if translator is None:
+            translator = TranslationService.get_translation(
+                language=self.instance.config["TRACKER_LANGUAGE"],
+                tracker_home=self.instance.config["TRACKER_HOME"])
+        self.translator = translator
+        self._ = self.gettext = translator.gettext
+        self.ngettext = translator.ngettext
+
+    def main(self):
+        ''' Wrap the real main in a try/finally so we always close off the db.
+        '''
+        try:
+            self.inner_main()
+        finally:
+            if hasattr(self, 'db'):
+                self.db.close()
+
+    def inner_main(self):
+        '''Process a request.
+
+        The most common requests are handled like so:
+
+        1. look for charset and language preferences, set up user locale
+           see determine_charset, determine_language
+        2. figure out who we are, defaulting to the "anonymous" user
+           see determine_user
+        3. figure out what the request is for - the context
+           see determine_context
+        4. handle any requested action (item edit, search, ...)
+           see handle_action
+        5. render a template, resulting in HTML output
+
+        In some situations, exceptions occur:
+
+        - HTTP Redirect  (generally raised by an action)
+        - SendFile       (generally raised by determine_context)
+          serve up a FileClass "content" property
+        - SendStaticFile (generally raised by determine_context)
+          serve up a file from the tracker "html" directory
+        - Unauthorised   (generally raised by an action)
+          the action is cancelled, the request is rendered and an error
+          message is displayed indicating that permission was not
+          granted for the action to take place
+        - templating.Unauthorised   (templating action not permitted)
+          raised by an attempted rendering of a template when the user
+          doesn't have permission
+        - NotFound       (raised wherever it needs to be)
+          percolates up to the CGI interface that called the client
+        '''
+        self.ok_message = []
+        self.error_message = []
+        try:
+            self.determine_charset()
+            self.determine_language()
+
+            # make sure we're identified (even anonymously)
+            self.determine_user()
+
+            # figure out the context and desired content template
+            self.determine_context()
+
+            # possibly handle a form submit action (may change self.classname
+            # and self.template, and may also append error/ok_messages)
+            html = self.handle_action()
+
+            if html:
+                self.write_html(html)
+                return
+
+            # now render the page
+            # we don't want clients caching our dynamic pages
+            self.additional_headers['Cache-Control'] = 'no-cache'
+# Pragma: no-cache makes Mozilla and its ilk double-load all pages!!
+#            self.additional_headers['Pragma'] = 'no-cache'
+
+            # pages with messages added expire right now
+            # simple views may be cached for a small amount of time
+            # TODO? make page expire time configurable
+            # <rj> always expire pages, as IE just doesn't seem to do the
+            # right thing here :(
+            date = time.time() - 1
+            #if self.error_message or self.ok_message:
+            #    date = time.time() - 1
+            #else:
+            #    date = time.time() + 5
+            self.additional_headers['Expires'] = rfc822.formatdate(date)
+
+            # render the content
+            self.write_html(self.renderContext())
+
+        except SeriousError, message:
+            self.write_html(str(message))
+        except Redirect, url:
+            # let's redirect - if the url isn't None, then we need to do
+            # the headers, otherwise the headers have been set before the
+            # exception was raised
+            if url:
+                self.additional_headers['Location'] = url
+                self.response_code = 302
+            self.write_html('Redirecting to <a href="%s">%s</a>'%(url, url))
+        except SendFile, designator:
+            try:
+                self.serve_file(designator)
+            except NotModified:
+                # send the 304 response
+                self.request.send_response(304)
+                self.request.end_headers()
+        except SendStaticFile, file:
+            try:
+                self.serve_static_file(str(file))
+            except NotModified:
+                # send the 304 response
+                self.request.send_response(304)
+                self.request.end_headers()
+        except Unauthorised, message:
+            # users may always see the front page
+            self.classname = self.nodeid = None
+            self.template = ''
+            self.error_message.append(message)
+            self.write_html(self.renderContext())
+        except NotFound:
+            # pass through
+            raise
+        except FormError, e:
+            self.error_message.append(self._('Form Error: ') + str(e))
+            self.write_html(self.renderContext())
+        except:
+            if self.instance.config.WEB_DEBUG:
+                self.write_html(cgitb.html(i18n=self.translator))
+            else:
+                self.mailer.exception_message()
+                return self.write_html(self._(error_message))
+
+    def clean_sessions(self):
+        """Age sessions, remove when they haven't been used for a week.
+
+        Do it only once an hour.
+
+        Note: also cleans One Time Keys, and other "session" based stuff.
+        """
+        sessions = self.db.getSessionManager()
+        last_clean = sessions.get('last_clean', 'last_use', 0)
+
+        # time to clean?
+        #week = 60*60*24*7
+        hour = 60*60
+        now = time.time()
+        if now - last_clean < hour:
+            return
+
+        sessions.clean(now)
+        self.db.getOTKManager().clean(now)
+        sessions.set('last_clean', last_use=time.time())
+        self.db.commit()
+
+    def determine_charset(self):
+        """Look for client charset in the form parameters or browser cookie.
+
+        If no charset requested by client, use storage charset (utf-8).
+
+        If the charset is found, and differs from the storage charset,
+        recode all form fields of type 'text/plain'
+        """
+        # look for client charset
+        charset_parameter = 0
+        if self.form.has_key('@charset'):
+            charset = self.form['@charset'].value
+            if charset.lower() == "none":
+                charset = ""
+            charset_parameter = 1
+        elif self.cookie.has_key('roundup_charset'):
+            charset = self.cookie['roundup_charset'].value
+        else:
+            charset = None
+        if charset:
+            # make sure the charset is recognized
+            try:
+                codecs.lookup(charset)
+            except LookupError:
+                self.error_message.append(self._('Unrecognized charset: %r')
+                    % charset)
+                charset_parameter = 0
+            else:
+                self.charset = charset.lower()
+        # If we've got a character set in request parameters,
+        # set the browser cookie to keep the preference.
+        # This is done after codecs.lookup to make sure
+        # that we aren't keeping a wrong value.
+        if charset_parameter:
+            self.add_cookie('roundup_charset', charset)
+
+        # if client charset is different from the storage charset,
+        # recode form fields
+        # XXX this requires FieldStorage from Python library.
+        #   mod_python FieldStorage is not supported!
+        if self.charset != self.STORAGE_CHARSET:
+            decoder = codecs.getdecoder(self.charset)
+            encoder = codecs.getencoder(self.STORAGE_CHARSET)
+            re_charref = re.compile('&#([0-9]+|x[0-9a-f]+);', re.IGNORECASE)
+            def _decode_charref(matchobj):
+                num = matchobj.group(1)
+                if num[0].lower() == 'x':
+                    uc = int(num[1:], 16)
+                else:
+                    uc = int(num)
+                return unichr(uc)
+
+            for field_name in self.form.keys():
+                field = self.form[field_name]
+                if (field.type == 'text/plain') and not field.filename:
+                    try:
+                        value = decoder(field.value)[0]
+                    except UnicodeError:
+                        continue
+                    value = re_charref.sub(_decode_charref, value)
+                    field.value = encoder(value)[0]
+
+    def determine_language(self):
+        """Determine the language"""
+        # look for language parameter
+        # then for language cookie
+        # last for the Accept-Language header
+        if self.form.has_key("@language"):
+            language = self.form["@language"].value
+            if language.lower() == "none":
+                language = ""
+            self.add_cookie("roundup_language", language)
+        elif self.cookie.has_key("roundup_language"):
+            language = self.cookie["roundup_language"].value
+        elif self.instance.config["WEB_USE_BROWSER_LANGUAGE"]:
+            hal = self.env.get('HTTP_ACCEPT_LANGUAGE')
+            language = accept_language.parse(hal)
+        else:
+            language = ""
+
+        self.language = language
+        if language:
+            self.setTranslator(TranslationService.get_translation(
+                    language,
+                    tracker_home=self.instance.config["TRACKER_HOME"]))
+
+    def determine_user(self):
+        """Determine who the user is"""
+        self.opendb('admin')
+
+        # make sure we have the session Class
+        self.clean_sessions()
+        sessions = self.db.getSessionManager()
+
+        user = None
+        # first up, try http authorization if enabled
+        if self.instance.config['WEB_HTTP_AUTH']:
+            if self.env.has_key('REMOTE_USER'):
+                # we have external auth (e.g. by Apache)
+                user = self.env['REMOTE_USER']
+            elif self.env.get('HTTP_AUTHORIZATION', ''):
+                # try handling Basic Auth ourselves
+                auth = self.env['HTTP_AUTHORIZATION']
+                scheme, challenge = auth.split(' ', 1)
+                if scheme.lower() == 'basic':
+                    try:
+                        decoded = base64.decodestring(challenge)
+                    except TypeError:
+                        # invalid challenge
+                        pass
+                    username, password = decoded.split(':')
+                    try:
+                        login = self.get_action_class('login')(self)
+                        login.verifyLogin(username, password)
+                    except LoginError, err:
+                        self.make_user_anonymous()
+                        self.response_code = 403
+                        raise Unauthorised, err
+
+                    user = username
+
+        # if user was not set by http authorization, try session cookie
+        if (not user and self.cookie.has_key(self.cookie_name)
+                and (self.cookie[self.cookie_name].value != 'deleted')):
+            # get the session key from the cookie
+            self.session = self.cookie[self.cookie_name].value
+            # get the user from the session
+            try:
+                # update the lifetime datestamp
+                sessions.updateTimestamp(self.session)
+                self.db.commit()
+                user = sessions.get(self.session, 'user')
+            except KeyError:
+                # not valid, ignore id
+                pass
+
+        # if no user name set by http authorization or session cookie
+        # the user is anonymous
+        if not user:
+            user = 'anonymous'
+
+        # sanity check on the user still being valid,
+        # getting the userid at the same time
+        try:
+            self.userid = self.db.user.lookup(user)
+        except (KeyError, TypeError):
+            user = 'anonymous'
+
+        # make sure the anonymous user is valid if we're using it
+        if user == 'anonymous':
+            self.make_user_anonymous()
+            if not self.db.security.hasPermission('Web Access', self.userid):
+                raise Unauthorised, self._("Anonymous users are not "
+                    "allowed to use the web interface")
+        else:
+            self.user = user
+
+        # reopen the database as the correct user
+        self.opendb(self.user)
+
+    def opendb(self, username):
+        """Open the database and set the current user.
+
+        Opens a database once. On subsequent calls only the user is set on
+        the database object the instance.optimize is set. If we are in
+        "Development Mode" (cf. roundup_server) then the database is always
+        re-opened.
+        """
+        # don't do anything if the db is open and the user has not changed
+        if hasattr(self, 'db') and self.db.isCurrentUser(username):
+            return
+
+        # open the database or only set the user
+        if not hasattr(self, 'db'):
+            self.db = self.instance.open(username)
+        else:
+            if self.instance.optimize:
+                self.db.setCurrentUser(username)
+            else:
+                self.db.close()
+                self.db = self.instance.open(username)
+
+    def determine_context(self, dre=re.compile(r'([^\d]+)0*(\d+)')):
+        """Determine the context of this page from the URL:
+
+        The URL path after the instance identifier is examined. The path
+        is generally only one entry long.
+
+        - if there is no path, then we are in the "home" context.
+        - if the path is "_file", then the additional path entry
+          specifies the filename of a static file we're to serve up
+          from the instance "html" directory. Raises a SendStaticFile
+          exception.(*)
+        - if there is something in the path (eg "issue"), it identifies
+          the tracker class we're to display.
+        - if the path is an item designator (eg "issue123"), then we're
+          to display a specific item.
+        - if the path starts with an item designator and is longer than
+          one entry, then we're assumed to be handling an item of a
+          FileClass, and the extra path information gives the filename
+          that the client is going to label the download with (ie
+          "file123/image.png" is nicer to download than "file123"). This
+          raises a SendFile exception.(*)
+
+        Both of the "*" types of contexts stop before we bother to
+        determine the template we're going to use. That's because they
+        don't actually use templates.
+
+        The template used is specified by the :template CGI variable,
+        which defaults to:
+
+        - only classname suplied:          "index"
+        - full item designator supplied:   "item"
+
+        We set:
+
+             self.classname  - the class to display, can be None
+
+             self.template   - the template to render the current context with
+
+             self.nodeid     - the nodeid of the class we're displaying
+        """
+        # default the optional variables
+        self.classname = None
+        self.nodeid = None
+
+        # see if a template or messages are specified
+        template_override = ok_message = error_message = None
+        for key in self.form.keys():
+            if self.FV_TEMPLATE.match(key):
+                template_override = self.form[key].value
+            elif self.FV_OK_MESSAGE.match(key):
+                ok_message = self.form[key].value
+                ok_message = clean_message(ok_message)
+            elif self.FV_ERROR_MESSAGE.match(key):
+                error_message = self.form[key].value
+                error_message = clean_message(error_message)
+
+        # see if we were passed in a message
+        if ok_message:
+            self.ok_message.append(ok_message)
+        if error_message:
+            self.error_message.append(error_message)
+
+        # determine the classname and possibly nodeid
+        path = self.path.split('/')
+        if not path or path[0] in ('', 'home', 'index'):
+            if template_override is not None:
+                self.template = template_override
+            else:
+                self.template = ''
+            return
+        elif path[0] in ('_file', '@@file'):
+            raise SendStaticFile, os.path.join(*path[1:])
+        else:
+            self.classname = path[0]
+            if len(path) > 1:
+                # send the file identified by the designator in path[0]
+                raise SendFile, path[0]
+
+        # see if we got a designator
+        m = dre.match(self.classname)
+        if m:
+            self.classname = m.group(1)
+            self.nodeid = m.group(2)
+            try:
+                klass = self.db.getclass(self.classname)
+            except KeyError:
+                raise NotFound, '%s/%s'%(self.classname, self.nodeid)
+            if not klass.hasnode(self.nodeid):
+                raise NotFound, '%s/%s'%(self.classname, self.nodeid)
+            # with a designator, we default to item view
+            self.template = 'item'
+        else:
+            # with only a class, we default to index view
+            self.template = 'index'
+
+        # make sure the classname is valid
+        try:
+            self.db.getclass(self.classname)
+        except KeyError:
+            raise NotFound, self.classname
+
+        # see if we have a template override
+        if template_override is not None:
+            self.template = template_override
+
+    def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')):
+        ''' Serve the file from the content property of the designated item.
+        '''
+        m = dre.match(str(designator))
+        if not m:
+            raise NotFound, str(designator)
+        classname, nodeid = m.group(1), m.group(2)
+
+        klass = self.db.getclass(classname)
+
+        # make sure we have the appropriate properties
+        props = klass.getprops()
+        if not props.has_key('type'):
+            raise NotFound, designator
+        if not props.has_key('content'):
+            raise NotFound, designator
+
+        # make sure we have permission
+        if not self.db.security.hasPermission('View', self.userid,
+                classname, 'content', nodeid):
+            raise Unauthorised, self._("You are not allowed to view "
+                "this file.")
+
+        mime_type = klass.get(nodeid, 'type')
+        content = klass.get(nodeid, 'content')
+        lmt = klass.get(nodeid, 'activity').timestamp()
+
+        self._serve_file(lmt, mime_type, content)
+
+    def serve_static_file(self, file):
+        ''' Serve up the file named from the templates dir
+        '''
+        # figure the filename - try STATIC_FILES, then TEMPLATES dir
+        for dir_option in ('STATIC_FILES', 'TEMPLATES'):
+            prefix = self.instance.config[dir_option]
+            if not prefix:
+                continue
+            # ensure the load doesn't try to poke outside
+            # of the static files directory
+            prefix = os.path.normpath(prefix)
+            filename = os.path.normpath(os.path.join(prefix, file))
+            if os.path.isfile(filename) and filename.startswith(prefix):
+                break
+        else:
+            raise NotFound, file
+
+        # last-modified time
+        lmt = os.stat(filename)[stat.ST_MTIME]
+
+        # detemine meta-type
+        file = str(file)
+        mime_type = mimetypes.guess_type(file)[0]
+        if not mime_type:
+            if file.endswith('.css'):
+                mime_type = 'text/css'
+            else:
+                mime_type = 'text/plain'
+
+        # snarf the content
+        f = open(filename, 'rb')
+        try:
+            content = f.read()
+        finally:
+            f.close()
+
+        self._serve_file(lmt, mime_type, content)
+
+    def _serve_file(self, lmt, mime_type, content):
+        ''' guts of serve_file() and serve_static_file()
+        '''
+        ims = None
+        # see if there's an if-modified-since...
+        if hasattr(self.request, 'headers'):
+            ims = self.request.headers.getheader('if-modified-since')
+        elif self.env.has_key('HTTP_IF_MODIFIED_SINCE'):
+            # cgi will put the header in the env var
+            ims = self.env['HTTP_IF_MODIFIED_SINCE']
+        if ims:
+            ims = rfc822.parsedate(ims)[:6]
+            lmtt = time.gmtime(lmt)[:6]
+            if lmtt <= ims:
+                raise NotModified
+
+        # spit out headers
+        self.additional_headers['Content-Type'] = mime_type
+        self.additional_headers['Content-Length'] = len(content)
+        lmt = rfc822.formatdate(lmt)
+        self.additional_headers['Last-Modified'] = lmt
+        self.write(content)
+
+    def renderContext(self):
+        ''' Return a PageTemplate for the named page
+        '''
+        name = self.classname
+        extension = self.template
+        pt = self.instance.templates.get(name, extension)
+
+        # catch errors so we can handle PT rendering errors more nicely
+        args = {
+            'ok_message': self.ok_message,
+            'error_message': self.error_message
+        }
+        try:
+            # let the template render figure stuff out
+            result = pt.render(self, None, None, **args)
+            self.additional_headers['Content-Type'] = pt.content_type
+            if self.env.get('CGI_SHOW_TIMING', ''):
+                if self.env['CGI_SHOW_TIMING'].upper() == 'COMMENT':
+                    timings = {'starttag': '<!-- ', 'endtag': ' -->'}
+                else:
+                    timings = {'starttag': '<p>', 'endtag': '</p>'}
+                timings['seconds'] = time.time()-self.start
+                s = self._('%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n'
+                    ) % timings
+                if hasattr(self.db, 'stats'):
+                    timings.update(self.db.stats)
+                    s += self._("%(starttag)sCache hits: %(cache_hits)d,"
+                        " misses %(cache_misses)d."
+                        " Loading items: %(get_items)f secs."
+                        " Filtering: %(filtering)f secs."
+                        "%(endtag)s\n") % timings
+                s += '</body>'
+                result = result.replace('</body>', s)
+            return result
+        except templating.NoTemplate, message:
+            return '<strong>%s</strong>'%message
+        except templating.Unauthorised, message:
+            raise Unauthorised, str(message)
+        except:
+            # everything else
+            return cgitb.pt_html(i18n=self.translator)
+
+    # these are the actions that are available
+    actions = (
+        ('edit',        EditItemAction),
+        ('editcsv',     EditCSVAction),
+        ('new',         NewItemAction),
+        ('register',    RegisterAction),
+        ('confrego',    ConfRegoAction),
+        ('passrst',     PassResetAction),
+        ('login',       LoginAction),
+        ('logout',      LogoutAction),
+        ('search',      SearchAction),
+        ('retire',      RetireAction),
+        ('show',        ShowAction),
+        ('export_csv',  ExportCSVAction),
+    )
+    def handle_action(self):
+        ''' Determine whether there should be an Action called.
+
+            The action is defined by the form variable :action which
+            identifies the method on this object to call. The actions
+            are defined in the "actions" sequence on this class.
+
+            Actions may return a page (by default HTML) to return to the
+            user, bypassing the usual template rendering.
+
+            We explicitly catch Reject and ValueError exceptions and
+            present their messages to the user.
+        '''
+        if self.form.has_key(':action'):
+            action = self.form[':action'].value.lower()
+        elif self.form.has_key('@action'):
+            action = self.form['@action'].value.lower()
+        else:
+            return None
+
+        try:
+            action_klass = self.get_action_class(action)
+
+            # call the mapped action
+            if isinstance(action_klass, type('')):
+                # old way of specifying actions
+                return getattr(self, action_klass)()
+            else:
+                return action_klass(self).execute()
+
+        except (ValueError, Reject), err:
+            self.error_message.append(str(err))
+
+    def get_action_class(self, action_name):
+        if (hasattr(self.instance, 'cgi_actions') and
+                self.instance.cgi_actions.has_key(action_name)):
+            # tracker-defined action
+            action_klass = self.instance.cgi_actions[action_name]
+        else:
+            # go with a default
+            for name, action_klass in self.actions:
+                if name == action_name:
+                    break
+            else:
+                raise ValueError, 'No such action "%s"'%action_name
+        return action_klass
+
+    def write(self, content):
+        if not self.headers_done:
+            self.header()
+        if self.env['REQUEST_METHOD'] != 'HEAD':
+            self.request.wfile.write(content)
+
+    def write_html(self, content):
+        if not self.headers_done:
+            # at this point, we are sure about Content-Type
+            self.additional_headers['Content-Type'] = \
+                'text/html; charset=%s' % self.charset
+            self.header()
+
+        if self.env['REQUEST_METHOD'] == 'HEAD':
+            # client doesn't care about content
+            return
+
+        if self.charset != self.STORAGE_CHARSET:
+            # recode output
+            content = content.decode(self.STORAGE_CHARSET, 'replace')
+            content = content.encode(self.charset, 'xmlcharrefreplace')
+
+        # and write
+        self.request.wfile.write(content)
+
+    def setHeader(self, header, value):
+        '''Override a header to be returned to the user's browser.
+        '''
+        self.additional_headers[header] = value
+
+    def header(self, headers=None, response=None):
+        '''Put up the appropriate header.
+        '''
+        if headers is None:
+            headers = {'Content-Type':'text/html; charset=utf-8'}
+        if response is None:
+            response = self.response_code
+
+        # update with additional info
+        headers.update(self.additional_headers)
+
+        if headers.get('Content-Type', 'text/html') == 'text/html':
+            headers['Content-Type'] = 'text/html; charset=utf-8'
+        self.request.send_response(response)
+        for entry in headers.items():
+            self.request.send_header(*entry)
+        for ((path, name), (value, expire)) in self.add_cookies.items():
+            cookie = "%s=%s; Path=%s;"%(name, value, path)
+            if expire is not None:
+                cookie += " expires=%s;"%Cookie._getdate(expire)
+            self.request.send_header('Set-Cookie', cookie)
+        self.request.end_headers()
+        self.headers_done = 1
+        if self.debug:
+            self.headers_sent = headers
+
+    def add_cookie(self, name, value, expire=86400*365, path=None):
+        """Set a cookie value to be sent in HTTP headers
+
+        Parameters:
+            name:
+                cookie name
+            value:
+                cookie value
+            expire:
+                cookie expiration time (seconds).
+                If value is empty (meaning "delete cookie"),
+                expiration time is forced in the past
+                and this argument is ignored.
+                If None, the cookie will expire at end-of-session.
+                If omitted, the cookie will be kept for a year.
+            path:
+                cookie path (optional)
+
+        """
+        if path is None:
+            path = self.cookie_path
+        if not value:
+            expire = -1
+        self.add_cookies[(path, name)] = (value, expire)
+
+    def set_cookie(self, user, expire=None):
+        """Set up a session cookie for the user.
+
+        Also store away the user's login info against the session.
+        """
+        sessions = self.db.getSessionManager()
+
+        # generate a unique session key
+        while 1:
+            s = '%s%s'%(time.time(), random.random())
+            s = binascii.b2a_base64(s).strip()
+            if not sessions.exists(s):
+                break
+        self.session = s
+
+        # clean up the base64
+        if self.session[-1] == '=':
+            if self.session[-2] == '=':
+                self.session = self.session[:-2]
+            else:
+                self.session = self.session[:-1]
+
+        # insert the session in the sessiondb
+        sessions.set(self.session, user=user)
+        self.db.commit()
+
+        # add session cookie
+        self.add_cookie(self.cookie_name, self.session, expire=expire)
+
+    def make_user_anonymous(self):
+        ''' Make us anonymous
+
+            This method used to handle non-existence of the 'anonymous'
+            user, but that user is mandatory now.
+        '''
+        self.userid = self.db.user.lookup('anonymous')
+        self.user = 'anonymous'
+
+    def standard_message(self, to, subject, body, author=None):
+        '''Send a standard email message from Roundup.
+
+        "to"      - recipients list
+        "subject" - Subject
+        "body"    - Message
+        "author"  - (name, address) tuple or None for admin email
+
+        Arguments are passed to the Mailer.standard_message code.
+        '''
+        try:
+            self.mailer.standard_message(to, subject, body, author)
+        except MessageSendError, e:
+            self.error_message.append(str(e))
+            return 0
+        return 1
+
+    def parsePropsFromForm(self, create=0):
+        return FormParser(self).parse(create=create)
+
+# vim: set et sts=4 sw=4 :

Added: tracker/vendor/roundup/current/roundup/cgi/exceptions.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/exceptions.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,62 @@
+#$Id: exceptions.py,v 1.6 2004/11/18 14:10:27 a1s Exp $
+'''Exceptions for use in Roundup's web interface.
+'''
+
+__docformat__ = 'restructuredtext'
+
+import cgi
+
+class HTTPException(Exception):
+    pass
+
+class LoginError(HTTPException):
+    pass
+
+class Unauthorised(HTTPException):
+    pass
+
+class Redirect(HTTPException):
+    pass
+
+class NotFound(HTTPException):
+    pass
+
+class NotModified(HTTPException):
+    pass
+
+class FormError(ValueError):
+    """An 'expected' exception occurred during form parsing.
+
+    That is, something we know can go wrong, and don't want to alarm the user
+    with.
+
+    We trap this at the user interface level and feed back a nice error to the
+    user.
+
+    """
+    pass
+
+class SendFile(Exception):
+    """Send a file from the database."""
+
+class SendStaticFile(Exception):
+    """Send a static file from the instance html directory."""
+
+class SeriousError(Exception):
+    """Raised when we can't reasonably display an error message on a
+    templated page.
+
+    The exception value will be displayed in the error page, HTML
+    escaped.
+    """
+    def __str__(self):
+        return '''
+<html><head><title>Roundup issue tracker: An error has occurred</title>
+ <link rel="stylesheet" type="text/css" href="@@file/style.css">
+</head>
+<body class="body" marginwidth="0" marginheight="0">
+ <p class="error-message">%s</p>
+</body></html>
+'''%cgi.escape(self.args[0])
+
+# vim: set filetype=python sts=4 sw=4 et si :

Added: tracker/vendor/roundup/current/roundup/cgi/form_parser.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/form_parser.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,560 @@
+import re, mimetypes
+
+from roundup import hyperdb, date, password
+from roundup.cgi import templating
+from roundup.cgi.exceptions import FormError
+
+class FormParser:
+    # edit form variable handling (see unit tests)
+    FV_LABELS = r'''
+       ^(
+         (?P<note>[@:]note)|
+         (?P<file>[@:]file)|
+         (
+          ((?P<classname>%s)(?P<id>[-\d]+))?  # optional leading designator
+          ((?P<required>[@:]required$)|       # :required
+           (
+            (
+             (?P<add>[@:]add[@:])|            # :add:<prop>
+             (?P<remove>[@:]remove[@:])|      # :remove:<prop>
+             (?P<confirm>[@:]confirm[@:])|    # :confirm:<prop>
+             (?P<link>[@:]link[@:])|          # :link:<prop>
+             ([@:])                           # just a separator
+            )?
+            (?P<propname>[^@:]+)             # <prop>
+           )
+          )
+         )
+        )$'''
+
+    def __init__(self, client):
+        self.client = client
+        self.db = client.db
+        self.form = client.form
+        self.classname = client.classname
+        self.nodeid = client.nodeid
+        try:
+            self._ = self.gettext = client.gettext
+            self.ngettext = client.ngettext
+        except AttributeError:
+            _translator = templating.translationService
+            self._ = self.gettext = _translator.gettext
+            self.ngettext = _translator.ngettext
+
+    def parse(self, create=0, num_re=re.compile('^\d+$')):
+        """ Item properties and their values are edited with html FORM
+            variables and their values. You can:
+
+            - Change the value of some property of the current item.
+            - Create a new item of any class, and edit the new item's
+              properties,
+            - Attach newly created items to a multilink property of the
+              current item.
+            - Remove items from a multilink property of the current item.
+            - Specify that some properties are required for the edit
+              operation to be successful.
+
+            In the following, <bracketed> values are variable, "@" may be
+            either ":" or "@", and other text "required" is fixed.
+
+            Most properties are specified as form variables:
+
+             <propname>
+              - property on the current context item
+
+             <designator>"@"<propname>
+              - property on the indicated item (for editing related
+                information)
+
+            Designators name a specific item of a class.
+
+            <classname><N>
+
+                Name an existing item of class <classname>.
+
+            <classname>"-"<N>
+
+                Name the <N>th new item of class <classname>. If the form
+                submission is successful, a new item of <classname> is
+                created. Within the submitted form, a particular
+                designator of this form always refers to the same new
+                item.
+
+            Once we have determined the "propname", we look at it to see
+            if it's special:
+
+            @required
+                The associated form value is a comma-separated list of
+                property names that must be specified when the form is
+                submitted for the edit operation to succeed.
+
+                When the <designator> is missing, the properties are
+                for the current context item.  When <designator> is
+                present, they are for the item specified by
+                <designator>.
+
+                The "@required" specifier must come before any of the
+                properties it refers to are assigned in the form.
+
+            @remove@<propname>=id(s) or @add@<propname>=id(s)
+                The "@add@" and "@remove@" edit actions apply only to
+                Multilink properties.  The form value must be a
+                comma-separate list of keys for the class specified by
+                the simple form variable.  The listed items are added
+                to (respectively, removed from) the specified
+                property.
+
+            @link@<propname>=<designator>
+                If the edit action is "@link@", the simple form
+                variable must specify a Link or Multilink property.
+                The form value is a comma-separated list of
+                designators.  The item corresponding to each
+                designator is linked to the property given by simple
+                form variable.  These are collected up and returned in
+                all_links.
+
+            None of the above (ie. just a simple form value)
+                The value of the form variable is converted
+                appropriately, depending on the type of the property.
+
+                For a Link('klass') property, the form value is a
+                single key for 'klass', where the key field is
+                specified in dbinit.py.
+
+                For a Multilink('klass') property, the form value is a
+                comma-separated list of keys for 'klass', where the
+                key field is specified in dbinit.py.
+
+                Note that for simple-form-variables specifiying Link
+                and Multilink properties, the linked-to class must
+                have a key field.
+
+                For a String() property specifying a filename, the
+                file named by the form value is uploaded. This means we
+                try to set additional properties "filename" and "type" (if
+                they are valid for the class).  Otherwise, the property
+                is set to the form value.
+
+                For Date(), Interval(), Boolean(), and Number()
+                properties, the form value is converted to the
+                appropriate
+
+            Any of the form variables may be prefixed with a classname or
+            designator.
+
+            Two special form values are supported for backwards
+            compatibility:
+
+            @note
+                This is equivalent to::
+
+                    @link at messages=msg-1
+                    msg-1 at content=value
+
+                except that in addition, the "author" and "date"
+                properties of "msg-1" are set to the userid of the
+                submitter, and the current time, respectively.
+
+            @file
+                This is equivalent to::
+
+                    @link at files=file-1
+                    file-1 at content=value
+
+                The String content value is handled as described above for
+                file uploads.
+
+            If both the "@note" and "@file" form variables are
+            specified, the action::
+
+                    @link at msg-1@files=file-1
+
+            is also performed.
+
+            We also check that FileClass items have a "content" property with
+            actual content, otherwise we remove them from all_props before
+            returning.
+
+            The return from this method is a dict of
+                (classname, id): properties
+            ... this dict _always_ has an entry for the current context,
+            even if it's empty (ie. a submission for an existing issue that
+            doesn't result in any changes would return {('issue','123'): {}})
+            The id may be None, which indicates that an item should be
+            created.
+        """
+        # some very useful variables
+        db = self.db
+        form = self.form
+
+        if not hasattr(self, 'FV_SPECIAL'):
+            # generate the regexp for handling special form values
+            classes = '|'.join(db.classes.keys())
+            # specials for parsePropsFromForm
+            # handle the various forms (see unit tests)
+            self.FV_SPECIAL = re.compile(self.FV_LABELS%classes, re.VERBOSE)
+            self.FV_DESIGNATOR = re.compile(r'(%s)([-\d]+)'%classes)
+
+        # these indicate the default class / item
+        default_cn = self.classname
+        default_cl = self.db.classes[default_cn]
+        default_nodeid = self.nodeid
+
+        # we'll store info about the individual class/item edit in these
+        all_required = {}       # required props per class/item
+        all_props = {}          # props to set per class/item
+        got_props = {}          # props received per class/item
+        all_propdef = {}        # note - only one entry per class
+        all_links = []          # as many as are required
+
+        # we should always return something, even empty, for the context
+        all_props[(default_cn, default_nodeid)] = {}
+
+        keys = form.keys()
+        timezone = db.getUserTimezone()
+
+        # sentinels for the :note and :file props
+        have_note = have_file = 0
+
+        # extract the usable form labels from the form
+        matches = []
+        for key in keys:
+            m = self.FV_SPECIAL.match(key)
+            if m:
+                matches.append((key, m.groupdict()))
+
+        # now handle the matches
+        for key, d in matches:
+            if d['classname']:
+                # we got a designator
+                cn = d['classname']
+                cl = self.db.classes[cn]
+                nodeid = d['id']
+                propname = d['propname']
+            elif d['note']:
+                # the special note field
+                cn = 'msg'
+                cl = self.db.classes[cn]
+                nodeid = '-1'
+                propname = 'content'
+                all_links.append((default_cn, default_nodeid, 'messages',
+                    [('msg', '-1')]))
+                have_note = 1
+            elif d['file']:
+                # the special file field
+                cn = 'file'
+                cl = self.db.classes[cn]
+                nodeid = '-1'
+                propname = 'content'
+                all_links.append((default_cn, default_nodeid, 'files',
+                    [('file', '-1')]))
+                have_file = 1
+            else:
+                # default
+                cn = default_cn
+                cl = default_cl
+                nodeid = default_nodeid
+                propname = d['propname']
+
+            # the thing this value relates to is...
+            this = (cn, nodeid)
+
+            # skip implicit create if this isn't a create action
+            if not create and nodeid is None:
+                continue
+
+            # get more info about the class, and the current set of
+            # form props for it
+            if not all_propdef.has_key(cn):
+                all_propdef[cn] = cl.getprops()
+            propdef = all_propdef[cn]
+            if not all_props.has_key(this):
+                all_props[this] = {}
+            props = all_props[this]
+            if not got_props.has_key(this):
+                got_props[this] = {}
+
+            # is this a link command?
+            if d['link']:
+                value = []
+                for entry in self.extractFormList(form[key]):
+                    m = self.FV_DESIGNATOR.match(entry)
+                    if not m:
+                        raise FormError, self._('link "%(key)s" '
+                            'value "%(value)s" not a designator') % locals()
+                    value.append((m.group(1), m.group(2)))
+
+                # make sure the link property is valid
+                if (not isinstance(propdef[propname], hyperdb.Multilink) and
+                        not isinstance(propdef[propname], hyperdb.Link)):
+                    raise FormError, self._('%(class)s %(property)s '
+                        'is not a link or multilink property') % {
+                        'class':cn, 'property':propname}
+
+                all_links.append((cn, nodeid, propname, value))
+                continue
+
+            # detect the special ":required" variable
+            if d['required']:
+                all_required[this] = self.extractFormList(form[key])
+                continue
+
+            # see if we're performing a special multilink action
+            mlaction = 'set'
+            if d['remove']:
+                mlaction = 'remove'
+            elif d['add']:
+                mlaction = 'add'
+
+            # does the property exist?
+            if not propdef.has_key(propname):
+                if mlaction != 'set':
+                    raise FormError, self._('You have submitted a %(action)s '
+                        'action for the property "%(property)s" '
+                        'which doesn\'t exist') % {
+                        'action': mlaction, 'property':propname}
+                # the form element is probably just something we don't care
+                # about - ignore it
+                continue
+            proptype = propdef[propname]
+
+            # Get the form value. This value may be a MiniFieldStorage
+            # or a list of MiniFieldStorages.
+            value = form[key]
+
+            # handle unpacking of the MiniFieldStorage / list form value
+            if isinstance(proptype, hyperdb.Multilink):
+                value = self.extractFormList(value)
+            else:
+                # multiple values are not OK
+                if isinstance(value, type([])):
+                    raise FormError, self._('You have submitted more than one '
+                        'value for the %s property') % propname
+                # value might be a file upload...
+                if not hasattr(value, 'filename') or value.filename is None:
+                    # nope, pull out the value and strip it
+                    value = value.value.strip()
+
+            # now that we have the props field, we need a teensy little
+            # extra bit of help for the old :note field...
+            if d['note'] and value:
+                props['author'] = self.db.getuid()
+                props['date'] = date.Date()
+
+            # handle by type now
+            if isinstance(proptype, hyperdb.Password):
+                if not value:
+                    # ignore empty password values
+                    continue
+                for key, d in matches:
+                    if d['confirm'] and d['propname'] == propname:
+                        confirm = form[key]
+                        break
+                else:
+                    raise FormError, self._('Password and confirmation text '
+                        'do not match')
+                if isinstance(confirm, type([])):
+                    raise FormError, self._('You have submitted more than one '
+                        'value for the %s property') % propname
+                if value != confirm.value:
+                    raise FormError, self._('Password and confirmation text '
+                        'do not match')
+                try:
+                    value = password.Password(value)
+                except hyperdb.HyperdbValueError, msg:
+                    raise FormError, msg
+
+            elif isinstance(proptype, hyperdb.Multilink):
+                # convert input to list of ids
+                try:
+                    l = hyperdb.rawToHyperdb(self.db, cl, nodeid,
+                        propname, value)
+                except hyperdb.HyperdbValueError, msg:
+                    raise FormError, msg
+
+                # now use that list of ids to modify the multilink
+                if mlaction == 'set':
+                    value = l
+                else:
+                    # we're modifying the list - get the current list of ids
+                    if props.has_key(propname):
+                        existing = props[propname]
+                    elif nodeid and not nodeid.startswith('-'):
+                        existing = cl.get(nodeid, propname, [])
+                    else:
+                        existing = []
+
+                    # now either remove or add
+                    if mlaction == 'remove':
+                        # remove - handle situation where the id isn't in
+                        # the list
+                        for entry in l:
+                            try:
+                                existing.remove(entry)
+                            except ValueError:
+                                raise FormError, self._('property '
+                                    '"%(propname)s": "%(value)s" '
+                                    'not currently in list') % {
+                                    'propname': propname, 'value': entry}
+                    else:
+                        # add - easy, just don't dupe
+                        for entry in l:
+                            if entry not in existing:
+                                existing.append(entry)
+                    value = existing
+                    value.sort()
+
+            elif value == '':
+                # other types should be None'd if there's no value
+                value = None
+            else:
+                # handle all other types
+                try:
+                    if isinstance(proptype, hyperdb.String):
+                        if (hasattr(value, 'filename') and
+                                value.filename is not None):
+                            # skip if the upload is empty
+                            if not value.filename:
+                                continue
+                            # this String is actually a _file_
+                            # try to determine the file content-type
+                            fn = value.filename.split('\\')[-1]
+                            if propdef.has_key('name'):
+                                props['name'] = fn
+                            # use this info as the type/filename properties
+                            if propdef.has_key('type'):
+                                if hasattr(value, 'type') and value.type:
+                                    props['type'] = value.type
+                                elif mimetypes.guess_type(fn)[0]:
+                                    props['type'] = mimetypes.guess_type(fn)[0]
+                                else:
+                                    props['type'] = "application/octet-stream"
+                            # finally, read the content RAW
+                            value = value.value
+                        else:
+                            value = hyperdb.rawToHyperdb(self.db, cl,
+                                nodeid, propname, value)
+
+                    else:
+                        value = hyperdb.rawToHyperdb(self.db, cl, nodeid,
+                            propname, value)
+                except hyperdb.HyperdbValueError, msg:
+                    raise FormError, msg
+
+            # register that we got this property
+            if value:
+                got_props[this][propname] = 1
+
+            # get the old value
+            if nodeid and not nodeid.startswith('-'):
+                try:
+                    existing = cl.get(nodeid, propname)
+                except KeyError:
+                    # this might be a new property for which there is
+                    # no existing value
+                    if not propdef.has_key(propname):
+                        raise
+                except IndexError, message:
+                    raise FormError(str(message))
+
+                # make sure the existing multilink is sorted
+                if isinstance(proptype, hyperdb.Multilink):
+                    existing.sort()
+
+                # "missing" existing values may not be None
+                if not existing:
+                    if isinstance(proptype, hyperdb.String):
+                        # some backends store "missing" Strings as empty strings
+                        if existing == self.db.BACKEND_MISSING_STRING:
+                            existing = None
+                    elif isinstance(proptype, hyperdb.Number):
+                        # some backends store "missing" Numbers as 0 :(
+                        if existing == self.db.BACKEND_MISSING_NUMBER:
+                            existing = None
+                    elif isinstance(proptype, hyperdb.Boolean):
+                        # likewise Booleans
+                        if existing == self.db.BACKEND_MISSING_BOOLEAN:
+                            existing = None
+
+                # if changed, set it
+                if value != existing:
+                    props[propname] = value
+            else:
+                # don't bother setting empty/unset values
+                if value is None:
+                    continue
+                elif isinstance(proptype, hyperdb.Multilink) and value == []:
+                    continue
+                elif isinstance(proptype, hyperdb.String) and value == '':
+                    continue
+
+                props[propname] = value
+
+        # check to see if we need to specially link a file to the note
+        if have_note and have_file:
+            all_links.append(('msg', '-1', 'files', [('file', '-1')]))
+
+        # see if all the required properties have been supplied
+        s = []
+        for thing, required in all_required.items():
+            # register the values we got
+            got = got_props.get(thing, {})
+            for entry in required[:]:
+                if got.has_key(entry):
+                    required.remove(entry)
+
+            # any required values not present?
+            if not required:
+                continue
+
+            # tell the user to entry the values required
+            s.append(self.ngettext(
+                'Required %(class)s property %(property)s not supplied',
+                'Required %(class)s properties %(property)s not supplied',
+                len(required)
+            ) % {
+                'class': self._(thing[0]),
+                'property': ', '.join(map(self.gettext, required))
+            })
+        if s:
+            raise FormError, '\n'.join(s)
+
+        # When creating a FileClass node, it should have a non-empty content
+        # property to be created. When editing a FileClass node, it should
+        # either have a non-empty content property or no property at all. In
+        # the latter case, nothing will change.
+        for (cn, id), props in all_props.items():
+            if (id == '-1') and not props:
+                # new item (any class) with no content - ignore
+                del all_props[(cn, id)]
+            elif isinstance(self.db.classes[cn], hyperdb.FileClass):
+                if id == '-1':
+                    if not props.get('content', ''):
+                        del all_props[(cn, id)]
+                elif props.has_key('content') and not props['content']:
+                    raise FormError, self._('File is empty')
+        return all_props, all_links
+
+    def extractFormList(self, value):
+        ''' Extract a list of values from the form value.
+
+            It may be one of:
+             [MiniFieldStorage('value'), MiniFieldStorage('value','value',...), ...]
+             MiniFieldStorage('value,value,...')
+             MiniFieldStorage('value')
+        '''
+        # multiple values are OK
+        if isinstance(value, type([])):
+            # it's a list of MiniFieldStorages - join then into
+            values = ','.join([i.value.strip() for i in value])
+        else:
+            # it's a MiniFieldStorage, but may be a comma-separated list
+            # of values
+            values = value.value
+
+        value = [i.strip() for i in values.split(',')]
+
+        # filter out the empty bits
+        return filter(None, value)
+
+# vim: set et sts=4 sw=4 :

Added: tracker/vendor/roundup/current/roundup/cgi/templating.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/templating.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,2595 @@
+"""Implements the API used in the HTML templating for the web interface.
+"""
+
+todo = '''
+- Most methods should have a "default" arg to supply a value
+  when none appears in the hyperdb or request.
+- Multilink property additions: change_note and new_upload
+- Add class.find() too
+- NumberHTMLProperty should support numeric operations
+- LinkHTMLProperty should handle comparisons to strings (cf. linked name)
+- HTMLRequest.default(self, sort, group, filter, columns, **filterspec):
+  """Set the request's view arguments to the given values when no
+     values are found in the CGI environment.
+  """
+- have menu() methods accept filtering arguments
+'''
+
+__docformat__ = 'restructuredtext'
+
+from __future__ import nested_scopes
+
+import sys, cgi, urllib, os, re, os.path, time, errno, mimetypes, csv
+import calendar
+
+from roundup import hyperdb, date, support
+from roundup import i18n
+from roundup.i18n import _
+
+try:
+    import cPickle as pickle
+except ImportError:
+    import pickle
+try:
+    import cStringIO as StringIO
+except ImportError:
+    import StringIO
+try:
+    from StructuredText.StructuredText import HTML as StructuredText
+except ImportError:
+    try: # older version
+        import StructuredText
+    except ImportError:
+        StructuredText = None
+
+# bring in the templating support
+from roundup.cgi.PageTemplates import PageTemplate, GlobalTranslationService
+from roundup.cgi.PageTemplates.Expressions import getEngine
+from roundup.cgi.TAL import TALInterpreter
+from roundup.cgi import TranslationService, ZTUtils
+
+### i18n services
+# this global translation service is not thread-safe.
+# it is left here for backward compatibility
+# until all Web UI translations are done via client.translator object
+translationService = TranslationService.get_translation()
+GlobalTranslationService.setGlobalTranslationService(translationService)
+
+### templating
+
+class NoTemplate(Exception):
+    pass
+
+class Unauthorised(Exception):
+    def __init__(self, action, klass, translator=None):
+        self.action = action
+        self.klass = klass
+        if translator:
+            self._ = translator.gettext
+        else:
+            self._ = TranslationService.get_translation().gettext
+    def __str__(self):
+        return self._('You are not allowed to %(action)s '
+            'items of class %(class)s') % {
+            'action': self.action, 'class': self.klass}
+
+def find_template(dir, name, view):
+    ''' Find a template in the nominated dir
+    '''
+    # find the source
+    if view:
+        filename = '%s.%s'%(name, view)
+    else:
+        filename = name
+
+    # try old-style
+    src = os.path.join(dir, filename)
+    if os.path.exists(src):
+        return (src, filename)
+
+    # try with a .html or .xml extension (new-style)
+    for extension in '.html', '.xml':
+        f = filename + extension
+        src = os.path.join(dir, f)
+        if os.path.exists(src):
+            return (src, f)
+
+    # no view == no generic template is possible
+    if not view:
+        raise NoTemplate, 'Template file "%s" doesn\'t exist'%name
+
+    # try for a _generic template
+    generic = '_generic.%s'%view
+    src = os.path.join(dir, generic)
+    if os.path.exists(src):
+        return (src, generic)
+
+    # finally, try _generic.html
+    generic = generic + '.html'
+    src = os.path.join(dir, generic)
+    if os.path.exists(src):
+        return (src, generic)
+
+    raise NoTemplate, 'No template file exists for templating "%s" '\
+        'with template "%s" (neither "%s" nor "%s")'%(name, view,
+        filename, generic)
+
+class Templates:
+    templates = {}
+
+    def __init__(self, dir):
+        self.dir = dir
+
+    def precompileTemplates(self):
+        ''' Go through a directory and precompile all the templates therein
+        '''
+        for filename in os.listdir(self.dir):
+            # skip subdirs
+            if os.path.isdir(filename):
+                continue
+
+            # skip files without ".html" or ".xml" extension - .css, .js etc.
+            for extension in '.html', '.xml':
+                if filename.endswith(extension):
+                    break
+            else:
+                continue
+
+            # remove extension
+            filename = filename[:-len(extension)]
+
+            # load the template
+            if '.' in filename:
+                name, extension = filename.split('.', 1)
+                self.get(name, extension)
+            else:
+                self.get(filename, None)
+
+    def get(self, name, extension=None):
+        ''' Interface to get a template, possibly loading a compiled template.
+
+            "name" and "extension" indicate the template we're after, which in
+            most cases will be "name.extension". If "extension" is None, then
+            we look for a template just called "name" with no extension.
+
+            If the file "name.extension" doesn't exist, we look for
+            "_generic.extension" as a fallback.
+        '''
+        # default the name to "home"
+        if name is None:
+            name = 'home'
+        elif extension is None and '.' in name:
+            # split name
+            name, extension = name.split('.')
+
+        # find the source
+        src, filename = find_template(self.dir, name, extension)
+
+        # has it changed?
+        try:
+            stime = os.stat(src)[os.path.stat.ST_MTIME]
+        except os.error, error:
+            if error.errno != errno.ENOENT:
+                raise
+
+        if self.templates.has_key(src) and \
+                stime <= self.templates[src].mtime:
+            # compiled template is up to date
+            return self.templates[src]
+
+        # compile the template
+        self.templates[src] = pt = RoundupPageTemplate()
+        # use pt_edit so we can pass the content_type guess too
+        content_type = mimetypes.guess_type(filename)[0] or 'text/html'
+        pt.pt_edit(open(src).read(), content_type)
+        pt.id = filename
+        pt.mtime = stime
+        return pt
+
+    def __getitem__(self, name):
+        name, extension = os.path.splitext(name)
+        if extension:
+            extension = extension[1:]
+        try:
+            return self.get(name, extension)
+        except NoTemplate, message:
+            raise KeyError, message
+
+def context(client, template=None, classname=None, request=None):
+    """Return the rendering context dictionary
+
+    The dictionary includes following symbols:
+
+    *context*
+     this is one of three things:
+
+     1. None - we're viewing a "home" page
+     2. The current class of item being displayed. This is an HTMLClass
+        instance.
+     3. The current item from the database, if we're viewing a specific
+        item, as an HTMLItem instance.
+
+    *request*
+      Includes information about the current request, including:
+
+       - the url
+       - the current index information (``filterspec``, ``filter`` args,
+         ``properties``, etc) parsed out of the form.
+       - methods for easy filterspec link generation
+       - *user*, the current user node as an HTMLItem instance
+       - *form*, the current CGI form information as a FieldStorage
+
+    *config*
+      The current tracker config.
+
+    *db*
+      The current database, used to access arbitrary database items.
+
+    *utils*
+      This is a special class that has its base in the TemplatingUtils
+      class in this file. If the tracker interfaces module defines a
+      TemplatingUtils class then it is mixed in, overriding the methods
+      in the base class.
+
+    *templates*
+      Access to all the tracker templates by name.
+      Used mainly in *use-macro* commands.
+
+    *template*
+      Current rendering template.
+
+    *true*
+      Logical True value.
+
+    *false*
+      Logical False value.
+
+    *i18n*
+      Internationalization service, providing string translation
+      methods ``gettext`` and ``ngettext``.
+
+    """
+    # construct the TemplatingUtils class
+    utils = TemplatingUtils
+    if (hasattr(client.instance, 'interfaces') and
+            hasattr(client.instance.interfaces, 'TemplatingUtils')):
+        class utils(client.instance.interfaces.TemplatingUtils, utils):
+            pass
+
+    # if template, classname and/or request are not passed explicitely,
+    # compute form client
+    if template is None:
+        template = client.template
+    if classname is None:
+        classname = client.classname
+    if request is None:
+        request = HTMLRequest(client)
+
+    c = {
+         'context': None,
+         'options': {},
+         'nothing': None,
+         'request': request,
+         'db': HTMLDatabase(client),
+         'config': client.instance.config,
+         'tracker': client.instance,
+         'utils': utils(client),
+         'templates': client.instance.templates,
+         'template': template,
+         'true': 1,
+         'false': 0,
+         'i18n': client.translator
+    }
+    # add in the item if there is one
+    if client.nodeid:
+        c['context'] = HTMLItem(client, classname, client.nodeid,
+            anonymous=1)
+    elif client.db.classes.has_key(classname):
+        c['context'] = HTMLClass(client, classname, anonymous=1)
+    return c
+
+class RoundupPageTemplate(PageTemplate.PageTemplate):
+    '''A Roundup-specific PageTemplate.
+
+    Interrogate the client to set up Roundup-specific template variables
+    to be available.  See 'context' function for the list of variables.
+
+    '''
+
+    # 06-jun-2004 [als] i am not sure if this method is used yet
+    def getContext(self, client, classname, request):
+        return context(client, self, classname, request)
+
+    def render(self, client, classname, request, **options):
+        """Render this Page Template"""
+
+        if not self._v_cooked:
+            self._cook()
+
+        __traceback_supplement__ = (PageTemplate.PageTemplateTracebackSupplement, self)
+
+        if self._v_errors:
+            raise PageTemplate.PTRuntimeError, \
+                'Page Template %s has errors.'%self.id
+
+        # figure the context
+        c = context(client, self, classname, request)
+        c.update({'options': options})
+
+        # and go
+        output = StringIO.StringIO()
+        TALInterpreter.TALInterpreter(self._v_program, self.macros,
+            getEngine().getContext(c), output, tal=1, strictinsert=0)()
+        return output.getvalue()
+
+    def __repr__(self):
+        return '<Roundup PageTemplate %r>'%self.id
+
+class HTMLDatabase:
+    ''' Return HTMLClasses for valid class fetches
+    '''
+    def __init__(self, client):
+        self._client = client
+        self._ = client._
+        self._db = client.db
+
+        # we want config to be exposed
+        self.config = client.db.config
+
+    def __getitem__(self, item, desre=re.compile(r'(?P<cl>\w+)(?P<id>[-\d]+)')):
+        # check to see if we're actually accessing an item
+        m = desre.match(item)
+        if m:
+            cl = m.group('cl')
+            self._client.db.getclass(cl)
+            return HTMLItem(self._client, cl, m.group('id'))
+        else:
+            self._client.db.getclass(item)
+            return HTMLClass(self._client, item)
+
+    def __getattr__(self, attr):
+        try:
+            return self[attr]
+        except KeyError:
+            raise AttributeError, attr
+
+    def classes(self):
+        l = self._client.db.classes.keys()
+        l.sort()
+        m = []
+        for item in l:
+            m.append(HTMLClass(self._client, item))
+        return m
+
+def lookupIds(db, prop, ids, fail_ok=0, num_re=re.compile('^-?\d+$')):
+    ''' "fail_ok" should be specified if we wish to pass through bad values
+        (most likely form values that we wish to represent back to the user)
+    '''
+    cl = db.getclass(prop.classname)
+    l = []
+    for entry in ids:
+        try:
+            l.append(cl.lookup(entry))
+        except (TypeError, KeyError):
+            # if fail_ok, ignore lookup error
+            # otherwise entry must be existing object id rather than key value
+            if fail_ok or num_re.match(entry):
+                l.append(entry)
+    return l
+
+def lookupKeys(linkcl, key, ids, num_re=re.compile('^-?\d+$')):
+    ''' Look up the "key" values for "ids" list - though some may already
+    be key values, not ids.
+    '''
+    l = []
+    for entry in ids:
+        if num_re.match(entry):
+            label = linkcl.get(entry, key)
+            # fall back to designator if label is None
+            if label is None: label = '%s%s'%(linkcl.classname, entry)
+            l.append(label)
+        else:
+            l.append(entry)
+    return l
+
+def input_html4(**attrs):
+    """Generate an 'input' (html4) element with given attributes"""
+    return '<input %s>'%' '.join(['%s="%s"'%item for item in attrs.items()])
+
+def input_xhtml(**attrs):
+    """Generate an 'input' (xhtml) element with given attributes"""
+    return '<input %s/>'%' '.join(['%s="%s"'%item for item in attrs.items()])
+
+class HTMLInputMixin:
+    ''' requires a _client property '''
+    def __init__(self):
+        html_version = 'html4'
+        if hasattr(self._client.instance.config, 'HTML_VERSION'):
+            html_version = self._client.instance.config.HTML_VERSION
+        if html_version == 'xhtml':
+            self.input = input_xhtml
+        else:
+            self.input = input_html4
+        # self._context is used for translations.
+        # will be initialized by the first call to .gettext()
+        self._context = None
+
+    def gettext(self, msgid):
+        """Return the localized translation of msgid"""
+        if self._context is None:
+            self._context = context(self._client)
+        return self._client.translator.translate(domain="roundup",
+            msgid=msgid, context=self._context)
+
+    _ = gettext
+
+class HTMLPermissions:
+
+    def view_check(self):
+        ''' Raise the Unauthorised exception if the user's not permitted to
+            view this class.
+        '''
+        if not self.is_view_ok():
+            raise Unauthorised("view", self._classname,
+                translator=self._client.translator)
+
+    def edit_check(self):
+        ''' Raise the Unauthorised exception if the user's not permitted to
+            edit items of this class.
+        '''
+        if not self.is_edit_ok():
+            raise Unauthorised("edit", self._classname,
+                translator=self._client.translator)
+
+
+class HTMLClass(HTMLInputMixin, HTMLPermissions):
+    ''' Accesses through a class (either through *class* or *db.<classname>*)
+    '''
+    def __init__(self, client, classname, anonymous=0):
+        self._client = client
+        self._ = client._
+        self._db = client.db
+        self._anonymous = anonymous
+
+        # we want classname to be exposed, but _classname gives a
+        # consistent API for extending Class/Item
+        self._classname = self.classname = classname
+        self._klass = self._db.getclass(self.classname)
+        self._props = self._klass.getprops()
+
+        HTMLInputMixin.__init__(self)
+
+    def is_edit_ok(self):
+        ''' Is the user allowed to Create the current class?
+        '''
+        return self._db.security.hasPermission('Create', self._client.userid,
+            self._classname)
+
+    def is_view_ok(self):
+        ''' Is the user allowed to View the current class?
+        '''
+        return self._db.security.hasPermission('View', self._client.userid,
+            self._classname)
+
+    def is_only_view_ok(self):
+        ''' Is the user only allowed to View (ie. not Create) the current class?
+        '''
+        return self.is_view_ok() and not self.is_edit_ok()
+
+    def __repr__(self):
+        return '<HTMLClass(0x%x) %s>'%(id(self), self.classname)
+
+    def __getitem__(self, item):
+        ''' return an HTMLProperty instance
+        '''
+       #print 'HTMLClass.getitem', (self, item)
+
+        # we don't exist
+        if item == 'id':
+            return None
+
+        # get the property
+        try:
+            prop = self._props[item]
+        except KeyError:
+            raise KeyError, 'No such property "%s" on %s'%(item, self.classname)
+
+        # look up the correct HTMLProperty class
+        form = self._client.form
+        for klass, htmlklass in propclasses:
+            if not isinstance(prop, klass):
+                continue
+            if form.has_key(item):
+                if isinstance(prop, hyperdb.Multilink):
+                    value = lookupIds(self._db, prop,
+                        handleListCGIValue(form[item]), fail_ok=1)
+                elif isinstance(prop, hyperdb.Link):
+                    value = form[item].value.strip()
+                    if value:
+                        value = lookupIds(self._db, prop, [value],
+                            fail_ok=1)[0]
+                    else:
+                        value = None
+                else:
+                    value = form[item].value.strip() or None
+            else:
+                if isinstance(prop, hyperdb.Multilink):
+                    value = []
+                else:
+                    value = None
+            return htmlklass(self._client, self._classname, '', prop, item,
+                value, self._anonymous)
+
+        # no good
+        raise KeyError, item
+
+    def __getattr__(self, attr):
+        ''' convenience access '''
+        try:
+            return self[attr]
+        except KeyError:
+            raise AttributeError, attr
+
+    def designator(self):
+        ''' Return this class' designator (classname) '''
+        return self._classname
+
+    def getItem(self, itemid, num_re=re.compile('^-?\d+$')):
+        ''' Get an item of this class by its item id.
+        '''
+        # make sure we're looking at an itemid
+        if not isinstance(itemid, type(1)) and not num_re.match(itemid):
+            itemid = self._klass.lookup(itemid)
+
+        return HTMLItem(self._client, self.classname, itemid)
+
+    def properties(self, sort=1):
+        ''' Return HTMLProperty for all of this class' properties.
+        '''
+        l = []
+        for name, prop in self._props.items():
+            for klass, htmlklass in propclasses:
+                if isinstance(prop, hyperdb.Multilink):
+                    value = []
+                else:
+                    value = None
+                if isinstance(prop, klass):
+                    l.append(htmlklass(self._client, self._classname, '',
+                        prop, name, value, self._anonymous))
+        if sort:
+            l.sort(lambda a,b:cmp(a._name, b._name))
+        return l
+
+    def list(self, sort_on=None):
+        ''' List all items in this class.
+        '''
+        # get the list and sort it nicely
+        l = self._klass.list()
+        sortfunc = make_sort_function(self._db, self._classname, sort_on)
+        l.sort(sortfunc)
+
+        # check perms
+        check = self._client.db.security.hasPermission
+        userid = self._client.userid
+
+        l = [HTMLItem(self._client, self._classname, id) for id in l
+            if check('View', userid, self._classname, itemid=id)]
+
+        return l
+
+    def csv(self):
+        ''' Return the items of this class as a chunk of CSV text.
+        '''
+        props = self.propnames()
+        s = StringIO.StringIO()
+        writer = csv.writer(s)
+        writer.writerow(props)
+        for nodeid in self._klass.list():
+            l = []
+            for name in props:
+                value = self._klass.get(nodeid, name)
+                if value is None:
+                    l.append('')
+                elif isinstance(value, type([])):
+                    l.append(':'.join(map(str, value)))
+                else:
+                    l.append(str(self._klass.get(nodeid, name)))
+            writer.writerow(l)
+        return s.getvalue()
+
+    def propnames(self):
+        ''' Return the list of the names of the properties of this class.
+        '''
+        idlessprops = self._klass.getprops(protected=0).keys()
+        idlessprops.sort()
+        return ['id'] + idlessprops
+
+    def filter(self, request=None, filterspec={}, sort=(None,None),
+            group=(None,None)):
+        ''' Return a list of items from this class, filtered and sorted
+            by the current requested filterspec/filter/sort/group args
+
+            "request" takes precedence over the other three arguments.
+        '''
+        if request is not None:
+            filterspec = request.filterspec
+            sort = request.sort
+            group = request.group
+
+        check = self._db.security.hasPermission
+        userid = self._client.userid
+
+        l = [HTMLItem(self._client, self.classname, id)
+             for id in self._klass.filter(None, filterspec, sort, group)
+             if check('View', userid, self.classname, itemid=id)]
+        return l
+
+    def classhelp(self, properties=None, label=''"(list)", width='500',
+            height='400', property='', form='itemSynopsis',
+            pagesize=50, inputtype="checkbox", sort=None, filter=None):
+        '''Pop up a javascript window with class help
+
+        This generates a link to a popup window which displays the
+        properties indicated by "properties" of the class named by
+        "classname". The "properties" should be a comma-separated list
+        (eg. 'id,name,description'). Properties defaults to all the
+        properties of a class (excluding id, creator, created and
+        activity).
+
+        You may optionally override the label displayed, the width,
+        the height, the number of items per page and the field on which
+        the list is sorted (defaults to username if in the displayed
+        properties).
+
+        With the "filter" arg it is possible to specify a filter for
+        which items are supposed to be displayed. It has to be of
+        the format "<field>=<values>;<field>=<values>;...".
+
+        The popup window will be resizable and scrollable.
+
+        If the "property" arg is given, it's passed through to the
+        javascript help_window function.
+
+        You can use inputtype="radio" to display a radio box instead
+        of the default checkbox (useful for entering Link-properties)
+
+        If the "form" arg is given, it's passed through to the
+        javascript help_window function. - it's the name of the form
+        the "property" belongs to.
+        '''
+        if properties is None:
+            properties = self._klass.getprops(protected=0).keys()
+            properties.sort()
+            properties = ','.join(properties)
+        if sort is None:
+            if 'username' in properties.split( ',' ):
+                sort = 'username'
+            else:
+                sort = find_sort_key(self._klass)
+        sort = '&amp;@sort=' + sort
+        if property:
+            property = '&amp;property=%s'%property
+        if form:
+            form = '&amp;form=%s'%form
+        if inputtype:
+            type= '&amp;type=%s'%inputtype
+        if filter:
+            filterprops = filter.split(';')
+            filtervalues = []
+            names = []
+            for x in filterprops:
+                (name, values) = x.split('=')
+                names.append(name)
+                filtervalues.append('&amp;%s=%s' % (name, urllib.quote(values)))
+            filter = '&amp;@filter=%s%s' % (','.join(names), ''.join(filtervalues))
+        else:
+           filter = ''
+        help_url = "%s?@startwith=0&amp;@template=help&amp;"\
+                   "properties=%s%s%s%s%s&amp;@pagesize=%s%s" % \
+                   (self.classname, properties, property, form, type,
+                   sort, pagesize, filter)
+        onclick = "javascript:help_window('%s', '%s', '%s');return false;" % \
+                  (help_url, width, height)
+        return '<a class="classhelp" href="%s" onclick="%s">%s</a>' % \
+               (help_url, onclick, self._(label))
+
+    def submit(self, label=''"Submit New Entry"):
+        ''' Generate a submit button (and action hidden element)
+
+        Generate nothing if we're not editable.
+        '''
+        if not self.is_edit_ok():
+            return ''
+
+        return self.input(type="hidden", name="@action", value="new") + \
+            '\n' + \
+            self.input(type="submit", name="submit", value=self._(label))
+
+    def history(self):
+        if not self.is_view_ok():
+            return self._('[hidden]')
+        return self._('New node - no history')
+
+    def renderWith(self, name, **kwargs):
+        ''' Render this class with the given template.
+        '''
+        # create a new request and override the specified args
+        req = HTMLRequest(self._client)
+        req.classname = self.classname
+        req.update(kwargs)
+
+        # new template, using the specified classname and request
+        pt = self._client.instance.templates.get(self.classname, name)
+
+        # use our fabricated request
+        args = {
+            'ok_message': self._client.ok_message,
+            'error_message': self._client.error_message
+        }
+        return pt.render(self._client, self.classname, req, **args)
+
+class _HTMLItem(HTMLInputMixin, HTMLPermissions):
+    ''' Accesses through an *item*
+    '''
+    def __init__(self, client, classname, nodeid, anonymous=0):
+        self._client = client
+        self._db = client.db
+        self._classname = classname
+        self._nodeid = nodeid
+        self._klass = self._db.getclass(classname)
+        self._props = self._klass.getprops()
+
+        # do we prefix the form items with the item's identification?
+        self._anonymous = anonymous
+
+        HTMLInputMixin.__init__(self)
+
+    def is_edit_ok(self):
+        ''' Is the user allowed to Edit the current class?
+        '''
+        return self._db.security.hasPermission('Edit', self._client.userid,
+            self._classname, itemid=self._nodeid)
+
+    def is_view_ok(self):
+        ''' Is the user allowed to View the current class?
+        '''
+        if self._db.security.hasPermission('View', self._client.userid,
+                self._classname, itemid=self._nodeid):
+            return 1
+        return self.is_edit_ok()
+
+    def is_only_view_ok(self):
+        ''' Is the user only allowed to View (ie. not Edit) the current class?
+        '''
+        return self.is_view_ok() and not self.is_edit_ok()
+
+    def __repr__(self):
+        return '<HTMLItem(0x%x) %s %s>'%(id(self), self._classname,
+            self._nodeid)
+
+    def __getitem__(self, item):
+        ''' return an HTMLProperty instance
+        '''
+        #print 'HTMLItem.getitem', (self, item)
+        if item == 'id':
+            return self._nodeid
+
+        # get the property
+        prop = self._props[item]
+
+        # get the value, handling missing values
+        value = None
+        if int(self._nodeid) > 0:
+            value = self._klass.get(self._nodeid, item, None)
+        if value is None:
+            if isinstance(self._props[item], hyperdb.Multilink):
+                value = []
+
+        # look up the correct HTMLProperty class
+        for klass, htmlklass in propclasses:
+            if isinstance(prop, klass):
+                return htmlklass(self._client, self._classname,
+                    self._nodeid, prop, item, value, self._anonymous)
+
+        raise KeyError, item
+
+    def __getattr__(self, attr):
+        ''' convenience access to properties '''
+        try:
+            return self[attr]
+        except KeyError:
+            raise AttributeError, attr
+
+    def designator(self):
+        """Return this item's designator (classname + id)."""
+        return '%s%s'%(self._classname, self._nodeid)
+
+    def is_retired(self):
+        """Is this item retired?"""
+        return self._klass.is_retired(self._nodeid)
+
+    def submit(self, label=''"Submit Changes"):
+        """Generate a submit button.
+
+        Also sneak in the lastactivity and action hidden elements.
+        """
+        return self.input(type="hidden", name="@lastactivity",
+            value=self.activity.local(0)) + '\n' + \
+            self.input(type="hidden", name="@action", value="edit") + '\n' + \
+            self.input(type="submit", name="submit", value=self._(label))
+
+    def journal(self, direction='descending'):
+        ''' Return a list of HTMLJournalEntry instances.
+        '''
+        # XXX do this
+        return []
+
+    def history(self, direction='descending', dre=re.compile('^\d+$')):
+        if not self.is_view_ok():
+            return self._('[hidden]')
+
+        # pre-load the history with the current state
+        current = {}
+        for prop_n in self._props.keys():
+            prop = self[prop_n]
+            if not isinstance(prop, HTMLProperty):
+                continue
+            current[prop_n] = prop.plain()
+            # make link if hrefable
+            if (self._props.has_key(prop_n) and
+                    isinstance(self._props[prop_n], hyperdb.Link)):
+                classname = self._props[prop_n].classname
+                try:
+                    template = find_template(self._db.config.TEMPLATES,
+                        classname, 'item')
+                    if template[1].startswith('_generic'):
+                        raise NoTemplate, 'not really...'
+                except NoTemplate:
+                    pass
+                else:
+                    id = self._klass.get(self._nodeid, prop_n, None)
+                    current[prop_n] = '<a href="%s%s">%s</a>'%(
+                        classname, id, current[prop_n])
+
+        # get the journal, sort and reverse
+        history = self._klass.history(self._nodeid)
+        history.sort()
+        history.reverse()
+
+        timezone = self._db.getUserTimezone()
+        l = []
+        comments = {}
+        for id, evt_date, user, action, args in history:
+            date_s = str(evt_date.local(timezone)).replace("."," ")
+            arg_s = ''
+            if action == 'link' and type(args) == type(()):
+                if len(args) == 3:
+                    linkcl, linkid, key = args
+                    arg_s += '<a href="%s%s">%s%s %s</a>'%(linkcl, linkid,
+                        linkcl, linkid, key)
+                else:
+                    arg_s = str(args)
+
+            elif action == 'unlink' and type(args) == type(()):
+                if len(args) == 3:
+                    linkcl, linkid, key = args
+                    arg_s += '<a href="%s%s">%s%s %s</a>'%(linkcl, linkid,
+                        linkcl, linkid, key)
+                else:
+                    arg_s = str(args)
+
+            elif type(args) == type({}):
+                cell = []
+                for k in args.keys():
+                    # try to get the relevant property and treat it
+                    # specially
+                    try:
+                        prop = self._props[k]
+                    except KeyError:
+                        prop = None
+                    if prop is None:
+                        # property no longer exists
+                        comments['no_exist'] = self._(
+                            "<em>The indicated property no longer exists</em>")
+                        cell.append(self._('<em>%s: %s</em>\n')
+                            % (self._(k), str(args[k])))
+                        continue
+
+                    if args[k] and (isinstance(prop, hyperdb.Multilink) or
+                            isinstance(prop, hyperdb.Link)):
+                        # figure what the link class is
+                        classname = prop.classname
+                        try:
+                            linkcl = self._db.getclass(classname)
+                        except KeyError:
+                            labelprop = None
+                            comments[classname] = self._(
+                                "The linked class %(classname)s no longer exists"
+                            ) % locals()
+                        labelprop = linkcl.labelprop(1)
+                        try:
+                            template = find_template(self._db.config.TEMPLATES,
+                                classname, 'item')
+                            if template[1].startswith('_generic'):
+                                raise NoTemplate, 'not really...'
+                            hrefable = 1
+                        except NoTemplate:
+                            hrefable = 0
+
+                    if isinstance(prop, hyperdb.Multilink) and args[k]:
+                        ml = []
+                        for linkid in args[k]:
+                            if isinstance(linkid, type(())):
+                                sublabel = linkid[0] + ' '
+                                linkids = linkid[1]
+                            else:
+                                sublabel = ''
+                                linkids = [linkid]
+                            subml = []
+                            for linkid in linkids:
+                                label = classname + linkid
+                                # if we have a label property, try to use it
+                                # TODO: test for node existence even when
+                                # there's no labelprop!
+                                try:
+                                    if labelprop is not None and \
+                                            labelprop != 'id':
+                                        label = linkcl.get(linkid, labelprop)
+                                except IndexError:
+                                    comments['no_link'] = self._(
+                                        "<strike>The linked node"
+                                        " no longer exists</strike>")
+                                    subml.append('<strike>%s</strike>'%label)
+                                else:
+                                    if hrefable:
+                                        subml.append('<a href="%s%s">%s</a>'%(
+                                            classname, linkid, label))
+                                    elif label is None:
+                                        subml.append('%s%s'%(classname,
+                                            linkid))
+                                    else:
+                                        subml.append(label)
+                            ml.append(sublabel + ', '.join(subml))
+                        cell.append('%s:\n  %s'%(self._(k), ', '.join(ml)))
+                    elif isinstance(prop, hyperdb.Link) and args[k]:
+                        label = classname + args[k]
+                        # if we have a label property, try to use it
+                        # TODO: test for node existence even when
+                        # there's no labelprop!
+                        if labelprop is not None and labelprop != 'id':
+                            try:
+                                label = linkcl.get(args[k], labelprop)
+                            except IndexError:
+                                comments['no_link'] = self._(
+                                    "<strike>The linked node"
+                                    " no longer exists</strike>")
+                                cell.append(' <strike>%s</strike>,\n'%label)
+                                # "flag" this is done .... euwww
+                                label = None
+                        if label is not None:
+                            if hrefable:
+                                old = '<a href="%s%s">%s</a>'%(classname, args[k], label)
+                            else:
+                                old = label;
+                            cell.append('%s: %s' % (self._(k), old))
+                            if current.has_key(k):
+                                cell[-1] += ' -> %s'%current[k]
+                                current[k] = old
+
+                    elif isinstance(prop, hyperdb.Date) and args[k]:
+                        if args[k] is None:
+                            d = ''
+                        else:
+                            d = date.Date(args[k],
+                                translator=self._client).local(timezone)
+                        cell.append('%s: %s'%(self._(k), str(d)))
+                        if current.has_key(k):
+                            cell[-1] += ' -> %s' % current[k]
+                            current[k] = str(d)
+
+                    elif isinstance(prop, hyperdb.Interval) and args[k]:
+                        val = str(date.Interval(args[k],
+                            translator=self._client))
+                        cell.append('%s: %s'%(self._(k), val))
+                        if current.has_key(k):
+                            cell[-1] += ' -> %s'%current[k]
+                            current[k] = val
+
+                    elif isinstance(prop, hyperdb.String) and args[k]:
+                        val = cgi.escape(args[k])
+                        cell.append('%s: %s'%(self._(k), val))
+                        if current.has_key(k):
+                            cell[-1] += ' -> %s'%current[k]
+                            current[k] = val
+
+                    elif isinstance(prop, hyperdb.Boolean) and args[k] is not None:
+                        val = args[k] and ''"Yes" or ''"No"
+                        cell.append('%s: %s'%(self._(k), val))
+                        if current.has_key(k):
+                            cell[-1] += ' -> %s'%current[k]
+                            current[k] = val
+
+                    elif not args[k]:
+                        if current.has_key(k):
+                            cell.append('%s: %s'%(self._(k), current[k]))
+                            current[k] = '(no value)'
+                        else:
+                            cell.append(self._('%s: (no value)')%self._(k))
+
+                    else:
+                        cell.append('%s: %s'%(self._(k), str(args[k])))
+                        if current.has_key(k):
+                            cell[-1] += ' -> %s'%current[k]
+                            current[k] = str(args[k])
+
+                arg_s = '<br />'.join(cell)
+            else:
+                # unkown event!!
+                comments['unknown'] = self._(
+                    "<strong><em>This event is not handled"
+                    " by the history display!</em></strong>")
+                arg_s = '<strong><em>' + str(args) + '</em></strong>'
+            date_s = date_s.replace(' ', '&nbsp;')
+            # if the user's an itemid, figure the username (older journals
+            # have the username)
+            if dre.match(user):
+                user = self._db.user.get(user, 'username')
+            l.append('<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>'%(
+                date_s, user, self._(action), arg_s))
+        if comments:
+            l.append(self._(
+                '<tr><td colspan=4><strong>Note:</strong></td></tr>'))
+        for entry in comments.values():
+            l.append('<tr><td colspan=4>%s</td></tr>'%entry)
+
+        if direction == 'ascending':
+            l.reverse()
+
+        l[0:0] = ['<table class="history">'
+             '<tr><th colspan="4" class="header">',
+             self._('History'),
+             '</th></tr><tr>',
+             self._('<th>Date</th>'),
+             self._('<th>User</th>'),
+             self._('<th>Action</th>'),
+             self._('<th>Args</th>'),
+            '</tr>']
+        l.append('</table>')
+        return '\n'.join(l)
+
+    def renderQueryForm(self):
+        ''' Render this item, which is a query, as a search form.
+        '''
+        # create a new request and override the specified args
+        req = HTMLRequest(self._client)
+        req.classname = self._klass.get(self._nodeid, 'klass')
+        name = self._klass.get(self._nodeid, 'name')
+        req.updateFromURL(self._klass.get(self._nodeid, 'url') +
+            '&@queryname=%s'%urllib.quote(name))
+
+        # new template, using the specified classname and request
+        pt = self._client.instance.templates.get(req.classname, 'search')
+
+        # use our fabricated request
+        return pt.render(self._client, req.classname, req)
+
+    def download_url(self):
+        ''' Assume that this item is a FileClass and that it has a name
+        and content. Construct a URL for the download of the content.
+        '''
+        name = self._klass.get(self._nodeid, 'name')
+        url = '%s%s/%s'%(self._classname, self._nodeid, name)
+        return urllib.quote(url)
+
+    def copy_url(self, exclude=("messages", "files")):
+        """Construct a URL for creating a copy of this item
+
+        "exclude" is an optional list of properties that should
+        not be copied to the new object.  By default, this list
+        includes "messages" and "files" properties.  Note that
+        "id" property cannot be copied.
+
+        """
+        exclude = ("id", "activity", "actor", "creation", "creator") \
+            + tuple(exclude)
+        query = {
+            "@template": "item",
+            "@note": self._("Copy of %(class)s %(id)s") % {
+                "class": self._(self._classname), "id": self._nodeid},
+        }
+        for name in self._props.keys():
+            if name not in exclude:
+                query[name] = self[name].plain()
+        return self._classname + "?" + "&".join(
+            ["%s=%s" % (key, urllib.quote(value))
+                for key, value in query.items()])
+
+class _HTMLUser(_HTMLItem):
+    '''Add ability to check for permissions on users.
+    '''
+    _marker = []
+    def hasPermission(self, permission, classname=_marker,
+            property=None, itemid=None):
+        '''Determine if the user has the Permission.
+
+        The class being tested defaults to the template's class, but may
+        be overidden for this test by suppling an alternate classname.
+        '''
+        if classname is self._marker:
+            classname = self._client.classname
+        return self._db.security.hasPermission(permission,
+            self._nodeid, classname, property, itemid)
+
+    def hasRole(self, rolename):
+        '''Determine whether the user has the Role.'''
+        roles = self._db.user.get(self._nodeid, 'roles').split(',')
+        for role in roles:
+            if role.strip() == rolename: return True
+        return False
+
+def HTMLItem(client, classname, nodeid, anonymous=0):
+    if classname == 'user':
+        return _HTMLUser(client, classname, nodeid, anonymous)
+    else:
+        return _HTMLItem(client, classname, nodeid, anonymous)
+
+class HTMLProperty(HTMLInputMixin, HTMLPermissions):
+    ''' String, Number, Date, Interval HTMLProperty
+
+        Has useful attributes:
+
+         _name  the name of the property
+         _value the value of the property if any
+
+        A wrapper object which may be stringified for the plain() behaviour.
+    '''
+    def __init__(self, client, classname, nodeid, prop, name, value,
+            anonymous=0):
+        self._client = client
+        self._db = client.db
+        self._ = client._
+        self._classname = classname
+        self._nodeid = nodeid
+        self._prop = prop
+        self._value = value
+        self._anonymous = anonymous
+        self._name = name
+        if not anonymous:
+            self._formname = '%s%s@%s'%(classname, nodeid, name)
+        else:
+            self._formname = name
+
+        HTMLInputMixin.__init__(self)
+
+    def __repr__(self):
+        return '<HTMLProperty(0x%x) %s %r %r>'%(id(self), self._formname,
+            self._prop, self._value)
+    def __str__(self):
+        return self.plain()
+    def __cmp__(self, other):
+        if isinstance(other, HTMLProperty):
+            return cmp(self._value, other._value)
+        return cmp(self._value, other)
+
+    def __nonzero__(self):
+        return not not self._value
+
+    def isset(self):
+        '''Is my _value not None?'''
+        return self._value is not None
+
+    def is_edit_ok(self):
+        '''Should the user be allowed to use an edit form field for this
+        property. Check "Create" for new items, or "Edit" for existing
+        ones.
+        '''
+        if self._nodeid:
+            return self._db.security.hasPermission('Edit', self._client.userid,
+                self._classname, self._name, self._nodeid)
+        return self._db.security.hasPermission('Create', self._client.userid,
+            self._classname, self._name)
+
+    def is_view_ok(self):
+        ''' Is the user allowed to View the current class?
+        '''
+        if self._db.security.hasPermission('View', self._client.userid,
+                self._classname, self._name, self._nodeid):
+            return 1
+        return self.is_edit_ok()
+
+class StringHTMLProperty(HTMLProperty):
+    hyper_re = re.compile(r'((?P<url>\w{3,6}://\S+)|'
+                          r'(?P<email>[-+=%/\w\.]+@[\w\.\-]+)|'
+                          r'(?P<item>(?P<class>[A-Za-z_]+)(\s*)(?P<id>\d+)))')
+    def _hyper_repl(self, match):
+        if match.group('url'):
+            s = match.group('url')
+            return '<a href="%s">%s</a>'%(s, s)
+        elif match.group('email'):
+            s = match.group('email')
+            return '<a href="mailto:%s">%s</a>'%(s, s)
+        else:
+            s = match.group('item')
+            s1 = match.group('class').lower()
+            s2 = match.group('id')
+            try:
+                # make sure s1 is a valid tracker classname
+                cl = self._db.getclass(s1)
+                if not cl.hasnode(s2):
+                    return s
+                return '<a href="%s%s">%s</a>'%(s1, s2, s)
+            except KeyError:
+                return s
+
+    def hyperlinked(self):
+        ''' Render a "hyperlinked" version of the text '''
+        return self.plain(hyperlink=1)
+
+    def plain(self, escape=0, hyperlink=0):
+        '''Render a "plain" representation of the property
+
+        - "escape" turns on/off HTML quoting
+        - "hyperlink" turns on/off in-text hyperlinking of URLs, email
+          addresses and designators
+        '''
+        if not self.is_view_ok():
+            return self._('[hidden]')
+
+        if self._value is None:
+            return ''
+        if escape:
+            s = cgi.escape(str(self._value))
+        else:
+            s = str(self._value)
+        if hyperlink:
+            # no, we *must* escape this text
+            if not escape:
+                s = cgi.escape(s)
+            s = self.hyper_re.sub(self._hyper_repl, s)
+        return s
+
+    def stext(self, escape=0, hyperlink=1):
+        ''' Render the value of the property as StructuredText.
+
+            This requires the StructureText module to be installed separately.
+        '''
+        if not self.is_view_ok():
+            return self._('[hidden]')
+
+        s = self.plain(escape=escape, hyperlink=hyperlink)
+        if not StructuredText:
+            return s
+        return StructuredText(s,level=1,header=0)
+
+    def field(self, **kwargs):
+        ''' Render the property as a field in HTML.
+
+            If not editable, just display the value via plain().
+        '''
+        if not self.is_edit_ok():
+            return self.plain()
+
+        if self._value is None:
+            value = ''
+        else:
+            value = cgi.escape(str(self._value))
+
+        value = '&quot;'.join(value.split('"'))
+
+        kwargs.setdefault("size", 30)
+        kwargs.update({"name": self._formname, "value": value})
+        return self.input(**kwargs)
+
+    def multiline(self, escape=0, rows=5, cols=40):
+        ''' Render a multiline form edit field for the property.
+
+            If not editable, just display the plain() value in a <pre> tag.
+        '''
+        if not self.is_edit_ok():
+            return '<pre>%s</pre>'%self.plain()
+
+        if self._value is None:
+            value = ''
+        else:
+            value = cgi.escape(str(self._value))
+
+        value = '&quot;'.join(value.split('"'))
+        return '<textarea name="%s" rows="%s" cols="%s">%s</textarea>'%(
+            self._formname, rows, cols, value)
+
+    def email(self, escape=1):
+        ''' Render the value of the property as an obscured email address
+        '''
+        if not self.is_view_ok():
+            return self._('[hidden]')
+
+        if self._value is None:
+            value = ''
+        else:
+            value = str(self._value)
+        split = value.split('@')
+        if len(split) == 2:
+            name, domain = split
+            domain = ' '.join(domain.split('.')[:-1])
+            name = name.replace('.', ' ')
+            value = '%s at %s ...'%(name, domain)
+        else:
+            value = value.replace('.', ' ')
+        if escape:
+            value = cgi.escape(value)
+        return value
+
+class PasswordHTMLProperty(HTMLProperty):
+    def plain(self):
+        ''' Render a "plain" representation of the property
+        '''
+        if not self.is_view_ok():
+            return self._('[hidden]')
+
+        if self._value is None:
+            return ''
+        return self._('*encrypted*')
+
+    def field(self, size = 30):
+        ''' Render a form edit field for the property.
+
+            If not editable, just display the value via plain().
+        '''
+        if not self.is_edit_ok():
+            return self.plain()
+
+        return self.input(type="password", name=self._formname, size=size)
+
+    def confirm(self, size = 30):
+        ''' Render a second form edit field for the property, used for
+            confirmation that the user typed the password correctly. Generates
+            a field with name "@confirm at name".
+
+            If not editable, display nothing.
+        '''
+        if not self.is_edit_ok():
+            return ''
+
+        return self.input(type="password",
+            name="@confirm@%s"%self._formname, size=size)
+
+class NumberHTMLProperty(HTMLProperty):
+    def plain(self):
+        ''' Render a "plain" representation of the property
+        '''
+        if not self.is_view_ok():
+            return self._('[hidden]')
+
+        if self._value is None:
+            return ''
+
+        return str(self._value)
+
+    def field(self, size = 30):
+        ''' Render a form edit field for the property.
+
+            If not editable, just display the value via plain().
+        '''
+        if not self.is_edit_ok():
+            return self.plain()
+
+        if self._value is None:
+            value = ''
+        else:
+            value = cgi.escape(str(self._value))
+
+        value = '&quot;'.join(value.split('"'))
+        return self.input(name=self._formname,value=value,size=size)
+
+    def __int__(self):
+        ''' Return an int of me
+        '''
+        return int(self._value)
+
+    def __float__(self):
+        ''' Return a float of me
+        '''
+        return float(self._value)
+
+
+class BooleanHTMLProperty(HTMLProperty):
+    def plain(self):
+        ''' Render a "plain" representation of the property
+        '''
+        if not self.is_view_ok():
+            return self._('[hidden]')
+
+        if self._value is None:
+            return ''
+        return self._value and self._("Yes") or self._("No")
+
+    def field(self):
+        ''' Render a form edit field for the property
+
+            If not editable, just display the value via plain().
+        '''
+        if not self.is_edit_ok():
+            return self.plain()
+
+        value = self._value
+        if isinstance(value, str) or isinstance(value, unicode):
+            value = value.strip().lower() in ('checked', 'yes', 'true',
+                'on', '1')
+
+        checked = value and "checked" or ""
+        if value:
+            s = self.input(type="radio", name=self._formname, value="yes",
+                checked="checked")
+            s += self._('Yes')
+            s +=self.input(type="radio", name=self._formname, value="no")
+            s += self._('No')
+        else:
+            s = self.input(type="radio", name=self._formname, value="yes")
+            s += self._('Yes')
+            s +=self.input(type="radio", name=self._formname, value="no",
+                checked="checked")
+            s += self._('No')
+        return s
+
+class DateHTMLProperty(HTMLProperty):
+
+    _marker = []
+
+    def __init__(self, client, classname, nodeid, prop, name, value,
+            anonymous=0, offset=None):
+        HTMLProperty.__init__(self, client, classname, nodeid, prop, name,
+                value, anonymous=anonymous)
+        if self._value and not (isinstance(self._value, str) or
+                isinstance(self._value, unicode)):
+            self._value.setTranslator(self._client.translator)
+        self._offset = offset
+        if self._offset is None :
+            self._offset = self._prop.offset (self._db)
+
+    def plain(self):
+        ''' Render a "plain" representation of the property
+        '''
+        if not self.is_view_ok():
+            return self._('[hidden]')
+
+        if self._value is None:
+            return ''
+        if self._offset is None:
+            offset = self._db.getUserTimezone()
+        else:
+            offset = self._offset
+        return str(self._value.local(offset))
+
+    def now(self, str_interval=None):
+        ''' Return the current time.
+
+            This is useful for defaulting a new value. Returns a
+            DateHTMLProperty.
+        '''
+        if not self.is_view_ok():
+            return self._('[hidden]')
+
+        ret = date.Date('.', translator=self._client)
+
+        if isinstance(str_interval, basestring):
+            sign = 1
+            if str_interval[0] == '-':
+                sign = -1
+                str_interval = str_interval[1:]
+            interval = date.Interval(str_interval, translator=self._client)
+            if sign > 0:
+                ret = ret + interval
+            else:
+                ret = ret - interval
+
+        return DateHTMLProperty(self._client, self._classname, self._nodeid,
+            self._prop, self._formname, ret)
+
+    def field(self, size=30, default=None, format=_marker):
+        '''Render a form edit field for the property
+
+        If not editable, just display the value via plain().
+
+        The format string is a standard python strftime format string.
+        '''
+        if not self.is_edit_ok():
+            if format is self._marker:
+                return self.plain()
+            else:
+                return self.pretty(format)
+
+        value = self._value
+
+        if value is None:
+            if default is None:
+                raw_value = None
+            else:
+                if isinstance(default, basestring):
+                    raw_value = Date(default, translator=self._client)
+                elif isinstance(default, date.Date):
+                    raw_value = default
+                elif isinstance(default, DateHTMLProperty):
+                    raw_value = default._value
+                else:
+                    raise ValueError, self._('default value for '
+                        'DateHTMLProperty must be either DateHTMLProperty '
+                        'or string date representation.')
+        elif isinstance(value, str) or isinstance(value, unicode):
+            # most likely erroneous input to be passed back to user
+            value = cgi.escape(str(value), 1)
+            return self.input(name=self._formname, value=value, size=size)
+        else:
+            raw_value = value
+
+        if raw_value is None:
+            value = ''
+        elif isinstance(raw_value, str) or isinstance(raw_value, unicode):
+            if format is self._marker:
+                value = raw_value
+            else:
+                value = date.Date(raw_value).pretty(format)
+        else:
+            if self._offset is None :
+                offset = self._db.getUserTimezone()
+            else :
+                offset = self._offset
+            value = raw_value.local(offset)
+            if format is not self._marker:
+                value = value.pretty(format)
+
+        value = cgi.escape(str(value), 1)
+        return self.input(name=self._formname, value=value, size=size)
+
+    def reldate(self, pretty=1):
+        ''' Render the interval between the date and now.
+
+            If the "pretty" flag is true, then make the display pretty.
+        '''
+        if not self.is_view_ok():
+            return self._('[hidden]')
+
+        if not self._value:
+            return ''
+
+        # figure the interval
+        interval = self._value - date.Date('.', translator=self._client)
+        if pretty:
+            return interval.pretty()
+        return str(interval)
+
+    def pretty(self, format=_marker):
+        ''' Render the date in a pretty format (eg. month names, spaces).
+
+            The format string is a standard python strftime format string.
+            Note that if the day is zero, and appears at the start of the
+            string, then it'll be stripped from the output. This is handy
+            for the situation when a date only specifies a month and a year.
+        '''
+        if not self.is_view_ok():
+            return self._('[hidden]')
+
+        if self._offset is None:
+            offset = self._db.getUserTimezone()
+        else:
+            offset = self._offset
+
+        if not self._value:
+            return ''
+        elif format is not self._marker:
+            return self._value.local(offset).pretty(format)
+        else:
+            return self._value.local(offset).pretty()
+
+    def local(self, offset):
+        ''' Return the date/time as a local (timezone offset) date/time.
+        '''
+        if not self.is_view_ok():
+            return self._('[hidden]')
+
+        return DateHTMLProperty(self._client, self._classname, self._nodeid,
+            self._prop, self._formname, self._value, offset=offset)
+
+    def popcal(self, width=300, height=200, label= "(cal)",
+            form="itemSynopsis"):
+        """Generate a link to a calendar pop-up window.
+
+        item: HTMLProperty e.g.: context.deadline
+        """
+        if self.isset():
+            date = "&date=%s"%self._value
+        else :
+            date = ""
+        return ('<a class="classhelp" href="javascript:help_window('
+            "'%s?@template=calendar&property=%s&form=%s%s', %d, %d)"
+            '">%s</a>'%(self._classname, self._name, form, date, width,
+            height, label))
+
+class IntervalHTMLProperty(HTMLProperty):
+    def __init__(self, client, classname, nodeid, prop, name, value,
+            anonymous=0):
+        HTMLProperty.__init__(self, client, classname, nodeid, prop,
+            name, value, anonymous)
+        if self._value and not isinstance(self._value, (str, unicode)):
+            self._value.setTranslator(self._client.translator)
+
+    def plain(self):
+        ''' Render a "plain" representation of the property
+        '''
+        if not self.is_view_ok():
+            return self._('[hidden]')
+
+        if self._value is None:
+            return ''
+        return str(self._value)
+
+    def pretty(self):
+        ''' Render the interval in a pretty format (eg. "yesterday")
+        '''
+        if not self.is_view_ok():
+            return self._('[hidden]')
+
+        return self._value.pretty()
+
+    def field(self, size = 30):
+        ''' Render a form edit field for the property
+
+            If not editable, just display the value via plain().
+        '''
+        if not self.is_edit_ok():
+            return self.plain()
+
+        if self._value is None:
+            value = ''
+        else:
+            value = cgi.escape(str(self._value))
+
+        value = '&quot;'.join(value.split('"'))
+        return self.input(name=self._formname,value=value,size=size)
+
+class LinkHTMLProperty(HTMLProperty):
+    ''' Link HTMLProperty
+        Include the above as well as being able to access the class
+        information. Stringifying the object itself results in the value
+        from the item being displayed. Accessing attributes of this object
+        result in the appropriate entry from the class being queried for the
+        property accessed (so item/assignedto/name would look up the user
+        entry identified by the assignedto property on item, and then the
+        name property of that user)
+    '''
+    def __init__(self, *args, **kw):
+        HTMLProperty.__init__(self, *args, **kw)
+        # if we're representing a form value, then the -1 from the form really
+        # should be a None
+        if str(self._value) == '-1':
+            self._value = None
+
+    def __getattr__(self, attr):
+        ''' return a new HTMLItem '''
+        if not self._value:
+            # handle a special page templates lookup
+            if attr == '__render_with_namespace__':
+                def nothing(*args, **kw):
+                    return ''
+                return nothing
+            msg = self._('Attempt to look up %(attr)s on a missing value')
+            return MissingValue(msg%locals())
+        i = HTMLItem(self._client, self._prop.classname, self._value)
+        return getattr(i, attr)
+
+    def plain(self, escape=0):
+        ''' Render a "plain" representation of the property
+        '''
+        if not self.is_view_ok():
+            return self._('[hidden]')
+
+        if self._value is None:
+            return ''
+        linkcl = self._db.classes[self._prop.classname]
+        k = linkcl.labelprop(1)
+        value = str(linkcl.get(self._value, k))
+        if escape:
+            value = cgi.escape(value)
+        return value
+
+    def field(self, showid=0, size=None):
+        ''' Render a form edit field for the property
+
+            If not editable, just display the value via plain().
+        '''
+        if not self.is_edit_ok():
+            return self.plain()
+
+        # edit field
+        linkcl = self._db.getclass(self._prop.classname)
+        if self._value is None:
+            value = ''
+        else:
+            k = linkcl.getkey()
+            if k:
+                value = linkcl.get(self._value, k)
+            else:
+                value = self._value
+            value = cgi.escape(str(value))
+            value = '&quot;'.join(value.split('"'))
+        return '<input name="%s" value="%s" size="%s">'%(self._formname,
+            value, size)
+
+    def menu(self, size=None, height=None, showid=0, additional=[], value=None,
+            sort_on=None, **conditions):
+        ''' Render a form select list for this property
+
+            "size" is used to limit the length of the list labels
+            "height" is used to set the <select> tag's "size" attribute
+            "showid" includes the item ids in the list labels
+            "value" specifies which item is pre-selected
+            "additional" lists properties which should be included in the
+                label
+            "sort_on" indicates the property to sort the list on as
+                (direction, property) where direction is '+' or '-'. A
+                single string with the direction prepended may be used.
+                For example: ('-', 'order'), '+name'.
+
+            The remaining keyword arguments are used as conditions for
+            filtering the items in the list - they're passed as the
+            "filterspec" argument to a Class.filter() call.
+
+            If not editable, just display the value via plain().
+        '''
+        if not self.is_edit_ok():
+            return self.plain()
+
+        if value is None:
+            value = self._value
+
+        linkcl = self._db.getclass(self._prop.classname)
+        l = ['<select name="%s">'%self._formname]
+        k = linkcl.labelprop(1)
+        s = ''
+        if value is None:
+            s = 'selected="selected" '
+        l.append(self._('<option %svalue="-1">- no selection -</option>')%s)
+
+        if sort_on is not None:
+            if not isinstance(sort_on, tuple):
+                if sort_on[0] in '+-':
+                    sort_on = (sort_on[0], sort_on[1:])
+                else:
+                    sort_on = ('+', sort_on)
+        else:
+            sort_on = ('+', find_sort_key(linkcl))
+
+        options = [opt
+            for opt in linkcl.filter(None, conditions, sort_on, (None, None))
+            if self._db.security.hasPermission("View", self._client.userid,
+                linkcl.classname, itemid=opt)]
+
+        # make sure we list the current value if it's retired
+        if value and value not in options:
+            options.insert(0, value)
+
+        for optionid in options:
+            # get the option value, and if it's None use an empty string
+            option = linkcl.get(optionid, k) or ''
+
+            # figure if this option is selected
+            s = ''
+            if value in [optionid, option]:
+                s = 'selected="selected" '
+
+            # figure the label
+            if showid:
+                lab = '%s%s: %s'%(self._prop.classname, optionid, option)
+            elif not option:
+                lab = '%s%s'%(self._prop.classname, optionid)
+            else:
+                lab = option
+
+            # truncate if it's too long
+            if size is not None and len(lab) > size:
+                lab = lab[:size-3] + '...'
+            if additional:
+                m = []
+                for propname in additional:
+                    m.append(linkcl.get(optionid, propname))
+                lab = lab + ' (%s)'%', '.join(map(str, m))
+
+            # and generate
+            lab = cgi.escape(lab)
+            l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab))
+        l.append('</select>')
+        return '\n'.join(l)
+#    def checklist(self, ...)
+
+
+
+class MultilinkHTMLProperty(HTMLProperty):
+    ''' Multilink HTMLProperty
+
+        Also be iterable, returning a wrapper object like the Link case for
+        each entry in the multilink.
+    '''
+    def __init__(self, *args, **kwargs):
+        HTMLProperty.__init__(self, *args, **kwargs)
+        if self._value:
+            display_value = lookupIds(self._db, self._prop, self._value,
+                fail_ok=1)
+            sortfun = make_sort_function(self._db, self._prop.classname)
+            # sorting fails if the value contains
+            # items not yet stored in the database
+            # ignore these errors to preserve user input
+            try:
+                display_value.sort(sortfun)
+            except:
+                pass
+            self._value = display_value
+
+    def __len__(self):
+        ''' length of the multilink '''
+        return len(self._value)
+
+    def __getattr__(self, attr):
+        ''' no extended attribute accesses make sense here '''
+        raise AttributeError, attr
+
+    def viewableGenerator(self, values):
+        '''Used to iterate over only the View'able items in a class.'''
+        check = self._db.security.hasPermission
+        userid = self._client.userid
+        classname = self._prop.classname
+        for value in values:
+            if check('View', userid, classname, itemid=value):
+                yield HTMLItem(self._client, classname, value)
+
+    def __iter__(self):
+        ''' iterate and return a new HTMLItem
+        '''
+        return self.viewableGenerator(self._value)
+
+    def reverse(self):
+        ''' return the list in reverse order
+        '''
+        l = self._value[:]
+        l.reverse()
+        return self.viewableGenerator(l)
+
+    def sorted(self, property):
+        ''' Return this multilink sorted by the given property '''
+        value = list(self.__iter__())
+        value.sort(lambda a,b:cmp(a[property], b[property]))
+        return value
+
+    def __contains__(self, value):
+        ''' Support the "in" operator. We have to make sure the passed-in
+            value is a string first, not a HTMLProperty.
+        '''
+        return str(value) in self._value
+
+    def isset(self):
+        '''Is my _value not []?'''
+        return self._value != []
+
+    def plain(self, escape=0):
+        ''' Render a "plain" representation of the property
+        '''
+        if not self.is_view_ok():
+            return self._('[hidden]')
+
+        linkcl = self._db.classes[self._prop.classname]
+        k = linkcl.labelprop(1)
+        labels = []
+        for v in self._value:
+            label = linkcl.get(v, k)
+            # fall back to designator if label is None
+            if label is None: label = '%s%s'%(self._prop.classname, k)
+            labels.append(label)
+        value = ', '.join(labels)
+        if escape:
+            value = cgi.escape(value)
+        return value
+
+    def field(self, size=30, showid=0):
+        ''' Render a form edit field for the property
+
+            If not editable, just display the value via plain().
+        '''
+        if not self.is_edit_ok():
+            return self.plain()
+
+        linkcl = self._db.getclass(self._prop.classname)
+        value = self._value[:]
+        # map the id to the label property
+        if not linkcl.getkey():
+            showid=1
+        if not showid:
+            k = linkcl.labelprop(1)
+            value = lookupKeys(linkcl, k, value)
+        value = cgi.escape(','.join(value))
+        return self.input(name=self._formname,size=size,value=value)
+
+    def menu(self, size=None, height=None, showid=0, additional=[],
+             value=None, sort_on=None, **conditions):
+        ''' Render a form <select> list for this property.
+
+            "size" is used to limit the length of the list labels
+            "height" is used to set the <select> tag's "size" attribute
+            "showid" includes the item ids in the list labels
+            "additional" lists properties which should be included in the
+                label
+            "value" specifies which item is pre-selected
+            "sort_on" indicates the property to sort the list on as
+                (direction, property) where direction is '+' or '-'. A
+                single string with the direction prepended may be used.
+                For example: ('-', 'order'), '+name'.
+
+            The remaining keyword arguments are used as conditions for
+            filtering the items in the list - they're passed as the
+            "filterspec" argument to a Class.filter() call.
+
+            If not editable, just display the value via plain().
+        '''
+        if not self.is_edit_ok():
+            return self.plain()
+
+        if value is None:
+            value = self._value
+
+        linkcl = self._db.getclass(self._prop.classname)
+
+        if sort_on is not None:
+            if not isinstance(sort_on, tuple):
+                if sort_on[0] in '+-':
+                    sort_on = (sort_on[0], sort_on[1:])
+                else:
+                    sort_on = ('+', sort_on)
+        else:
+            sort_on = ('+', find_sort_key(linkcl))
+
+        options = [opt
+            for opt in linkcl.filter(None, conditions, sort_on)
+            if self._db.security.hasPermission("View", self._client.userid,
+                linkcl.classname, itemid=opt)]
+        height = height or min(len(options), 7)
+        l = ['<select multiple name="%s" size="%s">'%(self._formname, height)]
+        k = linkcl.labelprop(1)
+
+        # make sure we list the current values if they're retired
+        for val in value:
+            if val not in options:
+                options.insert(0, val)
+
+        for optionid in options:
+            # get the option value, and if it's None use an empty string
+            option = linkcl.get(optionid, k) or ''
+
+            # figure if this option is selected
+            s = ''
+            if optionid in value or option in value:
+                s = 'selected="selected" '
+
+            # figure the label
+            if showid:
+                lab = '%s%s: %s'%(self._prop.classname, optionid, option)
+            else:
+                lab = option
+            # truncate if it's too long
+            if size is not None and len(lab) > size:
+                lab = lab[:size-3] + '...'
+            if additional:
+                m = []
+                for propname in additional:
+                    m.append(linkcl.get(optionid, propname))
+                lab = lab + ' (%s)'%', '.join(m)
+
+            # and generate
+            lab = cgi.escape(lab)
+            l.append('<option %svalue="%s">%s</option>'%(s, optionid,
+                lab))
+        l.append('</select>')
+        return '\n'.join(l)
+
+# set the propclasses for HTMLItem
+propclasses = (
+    (hyperdb.String, StringHTMLProperty),
+    (hyperdb.Number, NumberHTMLProperty),
+    (hyperdb.Boolean, BooleanHTMLProperty),
+    (hyperdb.Date, DateHTMLProperty),
+    (hyperdb.Interval, IntervalHTMLProperty),
+    (hyperdb.Password, PasswordHTMLProperty),
+    (hyperdb.Link, LinkHTMLProperty),
+    (hyperdb.Multilink, MultilinkHTMLProperty),
+)
+
+def make_sort_function(db, classname, sort_on=None):
+    '''Make a sort function for a given class
+    '''
+    linkcl = db.getclass(classname)
+    if sort_on is None:
+        sort_on = find_sort_key(linkcl)
+    def sortfunc(a, b):
+        return cmp(linkcl.get(a, sort_on), linkcl.get(b, sort_on))
+    return sortfunc
+
+def find_sort_key(linkcl):
+    if linkcl.getprops().has_key('order'):
+        return 'order'
+    else:
+        return linkcl.labelprop()
+
+def handleListCGIValue(value):
+    ''' Value is either a single item or a list of items. Each item has a
+        .value that we're actually interested in.
+    '''
+    if isinstance(value, type([])):
+        return [value.value for value in value]
+    else:
+        value = value.value.strip()
+        if not value:
+            return []
+        return value.split(',')
+
+class HTMLRequest(HTMLInputMixin):
+    '''The *request*, holding the CGI form and environment.
+
+    - "form" the CGI form as a cgi.FieldStorage
+    - "env" the CGI environment variables
+    - "base" the base URL for this instance
+    - "user" a HTMLItem instance for this user
+    - "classname" the current classname (possibly None)
+    - "template" the current template (suffix, also possibly None)
+
+    Index args:
+
+    - "columns" dictionary of the columns to display in an index page
+    - "show" a convenience access to columns - request/show/colname will
+      be true if the columns should be displayed, false otherwise
+    - "sort" index sort column (direction, column name)
+    - "group" index grouping property (direction, column name)
+    - "filter" properties to filter the index on
+    - "filterspec" values to filter the index on
+    - "search_text" text to perform a full-text search on for an index
+    '''
+    def __repr__(self):
+        return '<HTMLRequest %r>'%self.form
+
+    def __init__(self, client):
+        # _client is needed by HTMLInputMixin
+        self._client = self.client = client
+
+        # easier access vars
+        self.form = client.form
+        self.env = client.env
+        self.base = client.base
+        self.user = HTMLItem(client, 'user', client.userid)
+
+        # store the current class name and action
+        self.classname = client.classname
+        self.nodeid = client.nodeid
+        self.template = client.template
+
+        # the special char to use for special vars
+        self.special_char = '@'
+
+        HTMLInputMixin.__init__(self)
+
+        self._post_init()
+
+    def current_url(self):
+        url = self.base
+        if self.classname:
+            url += self.classname
+            if self.nodeid:
+                url += self.nodeid
+        args = {}
+        if self.template:
+            args['@template'] = self.template
+        return self.indexargs_url(url, args)
+
+    def _post_init(self):
+        ''' Set attributes based on self.form
+        '''
+        # extract the index display information from the form
+        self.columns = []
+        for name in ':columns @columns'.split():
+            if self.form.has_key(name):
+                self.special_char = name[0]
+                self.columns = handleListCGIValue(self.form[name])
+                break
+        self.show = support.TruthDict(self.columns)
+
+        # sorting
+        self.sort = (None, None)
+        for name in ':sort @sort'.split():
+            if self.form.has_key(name):
+                self.special_char = name[0]
+                sort = self.form[name].value
+                if sort.startswith('-'):
+                    self.sort = ('-', sort[1:])
+                else:
+                    self.sort = ('+', sort)
+                if self.form.has_key(self.special_char+'sortdir'):
+                    self.sort = ('-', self.sort[1])
+
+        # grouping
+        self.group = (None, None)
+        for name in ':group @group'.split():
+            if self.form.has_key(name):
+                self.special_char = name[0]
+                group = self.form[name].value
+                if group.startswith('-'):
+                    self.group = ('-', group[1:])
+                else:
+                    self.group = ('+', group)
+                if self.form.has_key(self.special_char+'groupdir'):
+                    self.group = ('-', self.group[1])
+
+        # filtering
+        self.filter = []
+        for name in ':filter @filter'.split():
+            if self.form.has_key(name):
+                self.special_char = name[0]
+                self.filter = handleListCGIValue(self.form[name])
+
+        self.filterspec = {}
+        db = self.client.db
+        if self.classname is not None:
+            props = db.getclass(self.classname).getprops()
+            for name in self.filter:
+                if not self.form.has_key(name):
+                    continue
+                prop = props[name]
+                fv = self.form[name]
+                if (isinstance(prop, hyperdb.Link) or
+                        isinstance(prop, hyperdb.Multilink)):
+                    self.filterspec[name] = lookupIds(db, prop,
+                        handleListCGIValue(fv))
+                else:
+                    if isinstance(fv, type([])):
+                        self.filterspec[name] = [v.value for v in fv]
+                    elif name == 'id':
+                        # special case "id" property
+                        self.filterspec[name] = handleListCGIValue(fv)
+                    else:
+                        self.filterspec[name] = fv.value
+
+        # full-text search argument
+        self.search_text = None
+        for name in ':search_text @search_text'.split():
+            if self.form.has_key(name):
+                self.special_char = name[0]
+                self.search_text = self.form[name].value
+
+        # pagination - size and start index
+        # figure batch args
+        self.pagesize = 50
+        for name in ':pagesize @pagesize'.split():
+            if self.form.has_key(name):
+                self.special_char = name[0]
+                self.pagesize = int(self.form[name].value)
+
+        self.startwith = 0
+        for name in ':startwith @startwith'.split():
+            if self.form.has_key(name):
+                self.special_char = name[0]
+                self.startwith = int(self.form[name].value)
+
+        # dispname
+        self.dispname = None
+        if self.form.has_key('@dispname'):
+            self.dispname = self.form['@dispname'].value
+
+    def updateFromURL(self, url):
+        ''' Parse the URL for query args, and update my attributes using the
+            values.
+        '''
+        env = {'QUERY_STRING': url}
+        self.form = cgi.FieldStorage(environ=env)
+
+        self._post_init()
+
+    def update(self, kwargs):
+        ''' Update my attributes using the keyword args
+        '''
+        self.__dict__.update(kwargs)
+        if kwargs.has_key('columns'):
+            self.show = support.TruthDict(self.columns)
+
+    def description(self):
+        ''' Return a description of the request - handle for the page title.
+        '''
+        s = [self.client.db.config.TRACKER_NAME]
+        if self.classname:
+            if self.client.nodeid:
+                s.append('- %s%s'%(self.classname, self.client.nodeid))
+            else:
+                if self.template == 'item':
+                    s.append('- new %s'%self.classname)
+                elif self.template == 'index':
+                    s.append('- %s index'%self.classname)
+                else:
+                    s.append('- %s %s'%(self.classname, self.template))
+        else:
+            s.append('- home')
+        return ' '.join(s)
+
+    def __str__(self):
+        d = {}
+        d.update(self.__dict__)
+        f = ''
+        for k in self.form.keys():
+            f += '\n      %r=%r'%(k,handleListCGIValue(self.form[k]))
+        d['form'] = f
+        e = ''
+        for k,v in self.env.items():
+            e += '\n     %r=%r'%(k, v)
+        d['env'] = e
+        return '''
+form: %(form)s
+base: %(base)r
+classname: %(classname)r
+template: %(template)r
+columns: %(columns)r
+sort: %(sort)r
+group: %(group)r
+filter: %(filter)r
+search_text: %(search_text)r
+pagesize: %(pagesize)r
+startwith: %(startwith)r
+env: %(env)s
+'''%d
+
+    def indexargs_form(self, columns=1, sort=1, group=1, filter=1,
+            filterspec=1, search_text=1):
+        ''' return the current index args as form elements '''
+        l = []
+        sc = self.special_char
+        s = self.input(type="hidden",name="%s",value="%s")
+        if columns and self.columns:
+            l.append(s%(sc+'columns', ','.join(self.columns)))
+        if sort and self.sort[1] is not None:
+            if self.sort[0] == '-':
+                val = '-'+self.sort[1]
+            else:
+                val = self.sort[1]
+            l.append(s%(sc+'sort', val))
+        if group and self.group[1] is not None:
+            if self.group[0] == '-':
+                val = '-'+self.group[1]
+            else:
+                val = self.group[1]
+            l.append(s%(sc+'group', val))
+        if filter and self.filter:
+            l.append(s%(sc+'filter', ','.join(self.filter)))
+        if self.classname and filterspec:
+            props = self.client.db.getclass(self.classname).getprops()
+            for k,v in self.filterspec.items():
+                if type(v) == type([]):
+                    if isinstance(props[k], hyperdb.String):
+                        l.append(s%(k, ' '.join(v)))
+                    else:
+                        l.append(s%(k, ','.join(v)))
+                else:
+                    l.append(s%(k, v))
+        if search_text and self.search_text:
+            l.append(s%(sc+'search_text', self.search_text))
+        l.append(s%(sc+'pagesize', self.pagesize))
+        l.append(s%(sc+'startwith', self.startwith))
+        return '\n'.join(l)
+
+    def indexargs_url(self, url, args):
+        ''' Embed the current index args in a URL
+        '''
+        q = urllib.quote
+        sc = self.special_char
+        l = ['%s=%s'%(k,v) for k,v in args.items()]
+
+        # pull out the special values (prefixed by @ or :)
+        specials = {}
+        for key in args.keys():
+            if key[0] in '@:':
+                specials[key[1:]] = args[key]
+
+        # ok, now handle the specials we received in the request
+        if self.columns and not specials.has_key('columns'):
+            l.append(sc+'columns=%s'%(','.join(self.columns)))
+        if self.sort[1] is not None and not specials.has_key('sort'):
+            if self.sort[0] == '-':
+                val = '-'+self.sort[1]
+            else:
+                val = self.sort[1]
+            l.append(sc+'sort=%s'%val)
+        if self.group[1] is not None and not specials.has_key('group'):
+            if self.group[0] == '-':
+                val = '-'+self.group[1]
+            else:
+                val = self.group[1]
+            l.append(sc+'group=%s'%val)
+        if self.filter and not specials.has_key('filter'):
+            l.append(sc+'filter=%s'%(','.join(self.filter)))
+        if self.search_text and not specials.has_key('search_text'):
+            l.append(sc+'search_text=%s'%q(self.search_text))
+        if not specials.has_key('pagesize'):
+            l.append(sc+'pagesize=%s'%self.pagesize)
+        if not specials.has_key('startwith'):
+            l.append(sc+'startwith=%s'%self.startwith)
+
+        # finally, the remainder of the filter args in the request
+        if self.classname and self.filterspec:
+            props = self.client.db.getclass(self.classname).getprops()
+            for k,v in self.filterspec.items():
+                if not args.has_key(k):
+                    if type(v) == type([]):
+                        if isinstance(props[k], hyperdb.String):
+                            l.append('%s=%s'%(k, '%20'.join([q(i) for i in v])))
+                        else:
+                            l.append('%s=%s'%(k, ','.join([q(i) for i in v])))
+                    else:
+                        l.append('%s=%s'%(k, q(v)))
+        return '%s?%s'%(url, '&'.join(l))
+    indexargs_href = indexargs_url
+
+    def base_javascript(self):
+        return '''
+<script type="text/javascript">
+submitted = false;
+function submit_once() {
+    if (submitted) {
+        alert("Your request is being processed.\\nPlease be patient.");
+        event.returnValue = 0;    // work-around for IE
+        return 0;
+    }
+    submitted = true;
+    return 1;
+}
+
+function help_window(helpurl, width, height) {
+    HelpWin = window.open('%s' + helpurl, 'RoundupHelpWindow', 'scrollbars=yes,resizable=yes,toolbar=no,height='+height+',width='+width);
+}
+</script>
+'''%self.base
+
+    def batch(self):
+        ''' Return a batch object for results from the "current search"
+        '''
+        filterspec = self.filterspec
+        sort = self.sort
+        group = self.group
+
+        # get the list of ids we're batching over
+        klass = self.client.db.getclass(self.classname)
+        if self.search_text:
+            matches = self.client.db.indexer.search(
+                [w.upper().encode("utf-8", "replace") for w in re.findall(
+                    r'(?u)\b\w{2,25}\b',
+                    unicode(self.search_text, "utf-8", "replace")
+                )], klass)
+        else:
+            matches = None
+
+        # filter for visibility
+        check = self._client.db.security.hasPermission
+        userid = self._client.userid
+        l = [id for id in klass.filter(matches, filterspec, sort, group)
+            if check('View', userid, self.classname, itemid=id)]
+
+        # return the batch object, using IDs only
+        return Batch(self.client, l, self.pagesize, self.startwith,
+            classname=self.classname)
+
+# extend the standard ZTUtils Batch object to remove dependency on
+# Acquisition and add a couple of useful methods
+class Batch(ZTUtils.Batch):
+    ''' Use me to turn a list of items, or item ids of a given class, into a
+        series of batches.
+
+        ========= ========================================================
+        Parameter  Usage
+        ========= ========================================================
+        sequence  a list of HTMLItems or item ids
+        classname if sequence is a list of ids, this is the class of item
+        size      how big to make the sequence.
+        start     where to start (0-indexed) in the sequence.
+        end       where to end (0-indexed) in the sequence.
+        orphan    if the next batch would contain less items than this
+                  value, then it is combined with this batch
+        overlap   the number of items shared between adjacent batches
+        ========= ========================================================
+
+        Attributes: Note that the "start" attribute, unlike the
+        argument, is a 1-based index (I know, lame).  "first" is the
+        0-based index.  "length" is the actual number of elements in
+        the batch.
+
+        "sequence_length" is the length of the original, unbatched, sequence.
+    '''
+    def __init__(self, client, sequence, size, start, end=0, orphan=0,
+            overlap=0, classname=None):
+        self.client = client
+        self.last_index = self.last_item = None
+        self.current_item = None
+        self.classname = classname
+        self.sequence_length = len(sequence)
+        ZTUtils.Batch.__init__(self, sequence, size, start, end, orphan,
+            overlap)
+
+    # overwrite so we can late-instantiate the HTMLItem instance
+    def __getitem__(self, index):
+        if index < 0:
+            if index + self.end < self.first: raise IndexError, index
+            return self._sequence[index + self.end]
+
+        if index >= self.length:
+            raise IndexError, index
+
+        # move the last_item along - but only if the fetched index changes
+        # (for some reason, index 0 is fetched twice)
+        if index != self.last_index:
+            self.last_item = self.current_item
+            self.last_index = index
+
+        item = self._sequence[index + self.first]
+        if self.classname:
+            # map the item ids to instances
+            item = HTMLItem(self.client, self.classname, item)
+        self.current_item = item
+        return item
+
+    def propchanged(self, property):
+        ''' Detect if the property marked as being the group property
+            changed in the last iteration fetch
+        '''
+        # we poke directly at the _value here since MissingValue can screw
+        # us up and cause Nones to compare strangely
+        if (self.last_item is None or
+                self.last_item[property]._value !=
+                self.current_item[property]._value):
+            return 1
+        return 0
+
+    # override these 'cos we don't have access to acquisition
+    def previous(self):
+        if self.start == 1:
+            return None
+        return Batch(self.client, self._sequence, self._size,
+            self.first - self._size + self.overlap, 0, self.orphan,
+            self.overlap)
+
+    def next(self):
+        try:
+            self._sequence[self.end]
+        except IndexError:
+            return None
+        return Batch(self.client, self._sequence, self._size,
+            self.end - self.overlap, 0, self.orphan, self.overlap)
+
+class TemplatingUtils:
+    ''' Utilities for templating
+    '''
+    def __init__(self, client):
+        self.client = client
+    def Batch(self, sequence, size, start, end=0, orphan=0, overlap=0):
+        return Batch(self.client, sequence, size, start, end, orphan,
+            overlap)
+
+    def url_quote(self, url):
+        '''URL-quote the supplied text.'''
+        return urllib.quote(url)
+
+    def html_quote(self, html):
+        '''HTML-quote the supplied text.'''
+        return cgi.escape(url)
+
+    def __getattr__(self, name):
+        '''Try the tracker's templating_utils.'''
+        if not hasattr(self.client.instance, 'templating_utils'):
+            # backwards-compatibility
+            raise AttributeError, name
+        if not self.client.instance.templating_utils.has_key(name):
+            raise AttributeError, name
+        return self.client.instance.templating_utils[name]
+
+    def html_calendar(self, request):
+        """Generate a HTML calendar.
+
+        `request`  the roundup.request object
+                   - @template : name of the template
+                   - form      : name of the form to store back the date
+                   - property  : name of the property of the form to store
+                                 back the date
+                   - date      : current date
+                   - display   : when browsing, specifies year and month
+
+        html will simply be a table.
+        """
+        date_str  = request.form.getfirst("date", ".")
+        display   = request.form.getfirst("display", date_str)
+        template  = request.form.getfirst("@template", "calendar")
+        form      = request.form.getfirst("form")
+        property  = request.form.getfirst("property")
+        curr_date = date.Date(date_str) # to highlight
+        display   = date.Date(display)  # to show
+        day       = display.day
+
+        # for navigation
+        date_prev_month = display + date.Interval("-1m")
+        date_next_month = display + date.Interval("+1m")
+        date_prev_year  = display + date.Interval("-1y")
+        date_next_year  = display + date.Interval("+1y")
+
+        res = []
+
+        base_link = "%s?@template=%s&property=%s&form=%s&date=%s" % \
+                    (request.classname, template, property, form, curr_date)
+
+        # navigation
+        # month
+        res.append('<table class="calendar"><tr><td>')
+        res.append(' <table width="100%" class="calendar_nav"><tr>')
+        link = "&display=%s"%date_prev_month
+        res.append('  <td><a href="%s&display=%s">&lt;</a></td>'%(base_link,
+            date_prev_month))
+        res.append('  <td>%s</td>'%calendar.month_name[display.month])
+        res.append('  <td><a href="%s&display=%s">&gt;</a></td>'%(base_link,
+            date_next_month))
+        # spacer
+        res.append('  <td width="100%"></td>')
+        # year
+        res.append('  <td><a href="%s&display=%s">&lt;</a></td>'%(base_link,
+            date_prev_year))
+        res.append('  <td>%s</td>'%display.year)
+        res.append('  <td><a href="%s&display=%s">&gt;</a></td>'%(base_link,
+            date_next_year))
+        res.append(' </tr></table>')
+        res.append(' </td></tr>')
+
+        # the calendar
+        res.append(' <tr><td><table class="calendar_display">')
+        res.append('  <tr class="weekdays">')
+        for day in calendar.weekheader(3).split():
+            res.append('   <td>%s</td>'%day)
+        res.append('  </tr>')
+        for week in calendar.monthcalendar(display.year, display.month):
+            res.append('  <tr>')
+            for day in week:
+                link = "javascript:form[field].value = '%d-%02d-%02d'; " \
+                      "window.close ();"%(display.year, display.month, day)
+                if (day == curr_date.day and display.month == curr_date.month
+                        and display.year == curr_date.year):
+                    # highlight
+                    style = "today"
+                else :
+                    style = ""
+                if day:
+                    res.append('   <td class="%s"><a href="%s">%s</a></td>'%(
+                        style, link, day))
+                else :
+                    res.append('   <td></td>')
+            res.append('  </tr>')
+        res.append('</table></td></tr></table>')
+        return "\n".join(res)
+
+class MissingValue:
+    def __init__(self, description, **kwargs):
+        self.__description = description
+        for key, value in kwargs.items():
+            self.__dict__[key] = value
+
+    def __call__(self, *args, **kwargs): return MissingValue(self.__description)
+    def __getattr__(self, name):
+        # This allows assignments which assume all intermediate steps are Null
+        # objects if they don't exist yet.
+        #
+        # For example (with just 'client' defined):
+        #
+        # client.db.config.TRACKER_WEB = 'BASE/'
+        self.__dict__[name] = MissingValue(self.__description)
+        return getattr(self, name)
+
+    def __getitem__(self, key): return self
+    def __nonzero__(self): return 0
+    def __str__(self): return '[%s]'%self.__description
+    def __repr__(self): return '<MissingValue 0x%x "%s">'%(id(self),
+        self.__description)
+    def gettext(self, str): return str
+    _ = gettext
+
+# vim: set et sts=4 sw=4 :

Added: tracker/vendor/roundup/current/roundup/cgi/zLOG.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/cgi/zLOG.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,2 @@
+def LOG(*args, **kw):
+    pass

Added: tracker/vendor/roundup/current/roundup/configuration.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/configuration.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,1236 @@
+# Roundup Issue Tracker configuration support
+#
+# $Id: configuration.py,v 1.34 2006/04/27 04:59:37 richard Exp $
+#
+__docformat__ = "restructuredtext"
+
+import getopt
+import imp
+import os
+import time
+import ConfigParser
+import logging, logging.config
+import sys
+
+# XXX i don't think this module needs string translation, does it?
+
+### Exceptions
+
+class ConfigurationError(Exception):
+
+    # without this, pychecker complains about missing class attribute...
+    args = ()
+
+class NoConfigError(ConfigurationError):
+
+    """Raised when configuration loading fails
+
+    Constructor parameters: path to the directory that was used as HOME
+
+    """
+
+    def __str__(self):
+        return "No valid configuration files found in directory %s" \
+            % self.args[0]
+
+class InvalidOptionError(ConfigurationError, KeyError, AttributeError):
+
+    """Attempted access to non-existing configuration option
+
+    Configuration options may be accessed as configuration object
+    attributes or items.  So this exception instances also are
+    instances of KeyError (invalid item access) and AttrributeError
+    (invalid attribute access).
+
+    Constructor parameter: option name
+
+    """
+
+    def __str__(self):
+        return "Unsupported configuration option: %s" % self.args[0]
+
+class OptionValueError(ConfigurationError, ValueError):
+
+    """Raised upon attempt to assign an invalid value to config option
+
+    Constructor parameters: Option instance, offending value
+    and optional info string.
+
+    """
+
+    def __str__(self):
+        _args = self.args
+        _rv = "Invalid value for %(option)s: %(value)r" % {
+            "option": _args[0].name, "value": _args[1]}
+        if len(_args) > 2:
+            _rv += "\n".join(("",) + _args[2:])
+        return _rv
+
+class OptionUnsetError(ConfigurationError):
+
+    """Raised when no Option value is available - neither set, nor default
+
+    Constructor parameters: Option instance.
+
+    """
+
+    def __str__(self):
+        return "%s is not set and has no default" % self.args[0].name
+
+class UnsetDefaultValue:
+
+    """Special object meaning that default value for Option is not specified"""
+
+    def __str__(self):
+        return "NO DEFAULT"
+
+NODEFAULT = UnsetDefaultValue()
+
+### Option classes
+
+class Option:
+
+    """Single configuration option.
+
+    Options have following attributes:
+
+        config
+            reference to the containing Config object
+        section
+            name of the section in the tracker .ini file
+        setting
+            option name in the tracker .ini file
+        default
+            default option value
+        description
+            option description.  Makes a comment in the tracker .ini file
+        name
+            "canonical name" of the configuration option.
+            For items in the 'main' section this is uppercased
+            'setting' name.  For other sections, the name is
+            composed of the section name and the setting name,
+            joined with underscore.
+        aliases
+            list of "also known as" names.  Used to access the settings
+            by old names used in previous Roundup versions.
+            "Canonical name" is also included.
+
+    The name and aliases are forced to be uppercase.
+    The setting name is forced to lowercase.
+
+    """
+
+    class_description = None
+
+    def __init__(self, config, section, setting,
+        default=NODEFAULT, description=None, aliases=None
+    ):
+        self.config = config
+        self.section = section
+        self.setting = setting.lower()
+        self.default = default
+        self.description = description
+        self.name = setting.upper()
+        if section != "main":
+            self.name = "_".join((section.upper(), self.name))
+        if aliases:
+            self.aliases = [alias.upper() for alias in list(aliases)]
+        else:
+            self.aliases = []
+        self.aliases.insert(0, self.name)
+        # convert default to internal representation
+        if default is NODEFAULT:
+            _value = default
+        else:
+            _value = self.str2value(default)
+        # value is private.  use get() and set() to access
+        self._value = self._default_value = _value
+
+    def str2value(self, value):
+        """Return 'value' argument converted to internal representation"""
+        return value
+
+    def _value2str(self, value):
+        """Return 'value' argument converted to external representation
+
+        This is actual conversion method called only when value
+        is not NODEFAULT.  Heirs with different conversion rules
+        override this method, not the public .value2str().
+
+        """
+        return str(value)
+
+    def value2str(self, value=NODEFAULT, current=0):
+        """Return 'value' argument converted to external representation
+
+        If 'current' is True, use current option value.
+
+        """
+        if current:
+            value = self._value
+        if value is NODEFAULT:
+            return str(value)
+        else:
+            return self._value2str(value)
+
+    def get(self):
+        """Return current option value"""
+        if self._value is NODEFAULT:
+            raise OptionUnsetError(self)
+        return self._value
+
+    def set(self, value):
+        """Update the value"""
+        self._value = self.str2value(value)
+
+    def reset(self):
+        """Reset the value to default"""
+        self._value = self._default_value
+
+    def isdefault(self):
+        """Return True if current value is the default one"""
+        return self._value == self._default_value
+
+    def isset(self):
+        """Return True if the value is avaliable (either set or default)"""
+        return self._value != NODEFAULT
+
+    def __str__(self):
+        return self.value2str(self._value)
+
+    def __repr__(self):
+        if self.isdefault():
+            _format = "<%(class)s %(name)s (default): %(value)s>"
+        else:
+            _format = "<%(class)s %(name)s (default: %(default)s): %(value)s>"
+        return _format % {
+            "class": self.__class__.__name__,
+            "name": self.name,
+            "default": self.value2str(self._default_value),
+            "value": self.value2str(self._value),
+        }
+
+    def format(self):
+        """Return .ini file fragment for this option"""
+        _desc_lines = []
+        for _description in (self.description, self.class_description):
+            if _description:
+                _desc_lines.extend(_description.split("\n"))
+        # comment out the setting line if there is no value
+        if self.isset():
+            _is_set = ""
+        else:
+            _is_set = "#"
+        _rv = "# %(description)s\n# Default: %(default)s\n" \
+            "%(is_set)s%(name)s = %(value)s\n" % {
+                "description": "\n# ".join(_desc_lines),
+                "default": self.value2str(self._default_value),
+                "name": self.setting,
+                "value": self.value2str(self._value),
+                "is_set": _is_set
+            }
+        return _rv
+
+    def load_ini(self, config):
+        """Load value from ConfigParser object"""
+        if config.has_option(self.section, self.setting):
+            self.set(config.get(self.section, self.setting))
+
+    def load_pyconfig(self, config):
+        """Load value from old-style config (python module)"""
+        for _name in self.aliases:
+            if hasattr(config, _name):
+                self.set(getattr(config, _name))
+                break
+
+class BooleanOption(Option):
+
+    """Boolean option: yes or no"""
+
+    class_description = "Allowed values: yes, no"
+
+    def _value2str(self, value):
+        if value:
+            return "yes"
+        else:
+            return "no"
+
+    def str2value(self, value):
+        if type(value) == type(""):
+            _val = value.lower()
+            if _val in ("yes", "true", "on", "1"):
+                _val = 1
+            elif _val in ("no", "false", "off", "0"):
+                _val = 0
+            else:
+                raise OptionValueError(self, value, self.class_description)
+        else:
+            _val = value and 1 or 0
+        return _val
+
+class WordListOption(Option):
+
+    """List of strings"""
+
+    class_description = "Allowed values: comma-separated list of words"
+
+    def _value2str(self, value):
+        return ','.join(value)
+
+    def str2value(self, value):
+        return value.split(',')
+
+class RunDetectorOption(Option):
+
+    """When a detector is run: always, never or for new items only"""
+
+    class_description = "Allowed values: yes, no, new"
+
+    def str2value(self, value):
+        _val = value.lower()
+        if _val in ("yes", "no", "new"):
+            return _val
+        else:
+            raise OptionValueError(self, value, self.class_description)
+
+class MailAddressOption(Option):
+
+    """Email address
+
+    Email addresses may be either fully qualified or local.
+    In the latter case MAIL_DOMAIN is automatically added.
+
+    """
+
+    def get(self):
+        _val = Option.get(self)
+        if "@" not in _val:
+            _val = "@".join((_val, self.config["MAIL_DOMAIN"]))
+        return _val
+
+class FilePathOption(Option):
+
+    """File or directory path name
+
+    Paths may be either absolute or relative to the HOME.
+
+    """
+
+    class_description = "The path may be either absolute or relative\n" \
+        "to the directory containig this config file."
+
+    def get(self):
+        _val = Option.get(self)
+        if _val and not os.path.isabs(_val):
+            _val = os.path.join(self.config["HOME"], _val)
+        return _val
+
+class FloatNumberOption(Option):
+
+    """Floating point numbers"""
+
+    def str2value(self, value):
+        try:
+            return float(value)
+        except ValueError:
+            raise OptionValueError(self, value,
+                "Floating point number required")
+
+    def _value2str(self, value):
+        _val = str(value)
+        # strip fraction part from integer numbers
+        if _val.endswith(".0"):
+            _val = _val[:-2]
+        return _val
+
+class IntegerNumberOption(Option):
+
+    """Integer numbers"""
+
+    def str2value(self, value):
+        try:
+            return int(value)
+        except ValueError:
+            raise OptionValueError(self, value, "Integer number required")
+
+class OctalNumberOption(Option):
+
+    """Octal Integer numbers"""
+
+    def str2value(self, value):
+        try:
+            return int(value, 8)
+        except ValueError:
+            raise OptionValueError(self, value, "Octal Integer number required")
+
+    def _value2str(self, value):
+        return oct(value)
+
+class NullableOption(Option):
+
+    """Option that is set to None if it's string value is one of NULL strings
+
+    Default nullable strings list contains empty string only.
+    There is constructor parameter allowing to specify different nullables.
+
+    Conversion to external representation returns the first of the NULL
+    strings list when the value is None.
+
+    """
+
+    NULL_STRINGS = ("",)
+
+    def __init__(self, config, section, setting,
+        default=NODEFAULT, description=None, aliases=None,
+        null_strings=NULL_STRINGS
+    ):
+        self.null_strings = list(null_strings)
+        Option.__init__(self, config, section, setting, default,
+            description, aliases)
+
+    def str2value(self, value):
+        if value in self.null_strings:
+            return None
+        else:
+            return value
+
+    def _value2str(self, value):
+        if value is None:
+            return self.null_strings[0]
+        else:
+            return value
+
+class NullableFilePathOption(NullableOption, FilePathOption):
+
+    # .get() and class_description are from FilePathOption,
+    get = FilePathOption.get
+    class_description = FilePathOption.class_description
+    # everything else taken from NullableOption (inheritance order)
+
+### Main configuration layout.
+# Config is described as a sequence of sections,
+# where each section name is followed by a sequence
+# of Option definitions.  Each Option definition
+# is a sequence containing class name and constructor
+# parameters, starting from the setting name:
+# setting, default, [description, [aliases]]
+# Note: aliases should only exist in historical options for backwards
+# compatibility - new options should *not* have aliases!
+SETTINGS = (
+    ("main", (
+        (FilePathOption, "database", "db", "Database directory path."),
+        (FilePathOption, "templates", "html",
+            "Path to the HTML templates directory."),
+        (NullableFilePathOption, "static_files", "",
+            "Path to directory holding additional static files\n"
+            "available via Web UI.  This directory may contain\n"
+            "sitewide images, CSS stylesheets etc. and is searched\n"
+            "for these files prior to the TEMPLATES directory\n"
+            "specified above.  If this option is not set, all static\n"
+            "files are taken from the TEMPLATES directory"),
+        (MailAddressOption, "admin_email", "roundup-admin",
+            "Email address that roundup will complain to"
+            " if it runs into trouble."),
+        (MailAddressOption, "dispatcher_email", "roundup-admin",
+            "The 'dispatcher' is a role that can get notified\n"
+            "of new items to the database.\n"
+            "It is used by the ERROR_MESSAGES_TO config setting."),
+        (Option, "email_from_tag", "",
+            "Additional text to include in the \"name\" part\n"
+            "of the From: address used in nosy messages.\n"
+            "If the sending user is \"Foo Bar\", the From: line\n"
+            "is usually: \"Foo Bar\" <issue_tracker at tracker.example>\n"
+            "the EMAIL_FROM_TAG goes inside the \"Foo Bar\" quotes like so:\n"
+            "\"Foo Bar EMAIL_FROM_TAG\" <issue_tracker at tracker.example>"),
+        (Option, "new_web_user_roles", "User",
+            "Roles that a user gets when they register"
+            " with Web User Interface.\n"
+            "This is a comma-separated string of role names"
+            " (e.g. 'Admin,User')."),
+        (Option, "new_email_user_roles", "User",
+            "Roles that a user gets when they register"
+            " with Email Gateway.\n"
+            "This is a comma-separated string of role names"
+            " (e.g. 'Admin,User')."),
+        (Option, "error_messages_to", "user",
+            # XXX This description needs better wording,
+            #   with explicit allowed values list.
+            "Send error message emails to the dispatcher, user, or both?\n"
+            "The dispatcher is configured using the DISPATCHER_EMAIL"
+            " setting."),
+        (Option, "html_version", "html4",
+            "HTML version to generate. The templates are html4 by default.\n"
+            "If you wish to make them xhtml, then you'll need to change this\n"
+            "var to 'xhtml' too so all auto-generated HTML is compliant.\n"
+            "Allowed values: html4, xhtml"),
+        # It seems to me that all timezone offsets in the modern world
+        # are integral hours.  However, there were fractional hour offsets
+        # in the past.  Use float number for sure.
+        (FloatNumberOption, "timezone", "0",
+            "Numeric timezone offset used when users do not choose their own\n"
+            "in their settings.",
+            ["DEFAULT_TIMEZONE"]),
+        (BooleanOption, "instant_registration", "no",
+            "Register new users instantly, or require confirmation via\n"
+            "email?"),
+        (BooleanOption, "email_registration_confirmation", "yes",
+            "Offer registration confirmation by email or only through the web?"),
+        (WordListOption, "indexer_stopwords", "",
+            "Additional stop-words for the full-text indexer specific to\n"
+            "your tracker. See the indexer source for the default list of\n"
+            "stop-words (eg. A,AND,ARE,AS,AT,BE,BUT,BY, ...)"),
+        (OctalNumberOption, "umask", "02",
+            "Defines the file creation mode mask."),
+    )),
+    ("tracker", (
+        (Option, "name", "Roundup issue tracker",
+            "A descriptive name for your roundup instance."),
+        (Option, "web", NODEFAULT,
+            "The web address that the tracker is viewable at.\n"
+            "This will be included in information"
+            " sent to users of the tracker.\n"
+            "The URL MUST include the cgi-bin part or anything else\n"
+            "that is required to get to the home page of the tracker.\n"
+            "You MUST include a trailing '/' in the URL."),
+        (MailAddressOption, "email", "issue_tracker",
+            "Email address that mail to roundup should go to."),
+        (NullableOption, "language", "",
+            "Default locale name for this tracker.\n"
+            "If this option is not set, the language is determined\n"
+            "by OS environment variable LANGUAGE, LC_ALL, LC_MESSAGES,\n"
+            "or LANG, in that order of preference."),
+    )),
+    ("web", (
+        (BooleanOption, 'http_auth', "yes",
+            "Whether to use HTTP Basic Authentication, if present.\n"
+            "Roundup will use either the REMOTE_USER or HTTP_AUTHORIZATION\n"
+            "variables supplied by your web server (in that order).\n"
+            "Set this option to 'no' if you do not wish to use HTTP Basic\n"
+            "Authentication in your web interface."),
+        (BooleanOption, 'use_browser_language', "yes",
+            "Whether to use HTTP Accept-Language, if present.\n"
+            "Browsers send a language-region preference list.\n"
+            "It's usually set in the client's browser or in their\n"
+            "Operating System.\n"
+            "Set this option to 'no' if you want to ignore it."),
+        (BooleanOption, "debug", "no",
+            "Setting this option makes Roundup display error tracebacks\n"
+            "in the user's browser rather than emailing them to the\n"
+            "tracker admin."),
+    )),
+    ("rdbms", (
+        (Option, 'name', 'roundup',
+            "Name of the database to use.",
+            ['MYSQL_DBNAME']),
+        (NullableOption, 'host', 'localhost',
+            "Database server host.",
+            ['MYSQL_DBHOST']),
+        (NullableOption, 'port', '',
+            "TCP port number of the database server.\n"
+            "Postgresql usually resides on port 5432 (if any),\n"
+            "for MySQL default port number is 3306.\n"
+            "Leave this option empty to use backend default"),
+        (NullableOption, 'user', 'roundup',
+            "Database user name that Roundup should use.",
+            ['MYSQL_DBUSER']),
+        (NullableOption, 'password', 'roundup',
+            "Database user password.",
+            ['MYSQL_DBPASSWORD']),
+        (NullableOption, 'read_default_file', '~/.my.cnf',
+            "Name of the MySQL defaults file.\n"
+            "Only used in MySQL connections."),
+        (NullableOption, 'read_default_group', 'roundup',
+            "Name of the group to use in the MySQL defaults file (.my.cnf).\n"
+            "Only used in MySQL connections."),
+    ), "Settings in this section are used"
+        " by Postgresql and MySQL backends only"
+    ),
+    ("logging", (
+        (FilePathOption, "config", "",
+            "Path to configuration file for standard Python logging module.\n"
+            "If this option is set, logging configuration is loaded\n"
+            "from specified file; options 'filename' and 'level'\n"
+            "in this section are ignored."),
+        (FilePathOption, "filename", "",
+            "Log file name for minimal logging facility built into Roundup.\n"
+            "If no file name specified, log messages are written on stderr.\n"
+            "If above 'config' option is set, this option has no effect."),
+        (Option, "level", "ERROR",
+            "Minimal severity level of messages written to log file.\n"
+            "If above 'config' option is set, this option has no effect.\n"
+            "Allowed values: DEBUG, INFO, WARNING, ERROR"),
+    )),
+    ("mail", (
+        (Option, "domain", NODEFAULT, "Domain name used for email addresses."),
+        (Option, "host", NODEFAULT,
+            "SMTP mail host that roundup will use to send mail",
+            ["MAILHOST"],),
+        (Option, "username", "", "SMTP login name.\n"
+            "Set this if your mail host requires authenticated access.\n"
+            "If username is not empty, password (below) MUST be set!"),
+        (Option, "password", NODEFAULT, "SMTP login password.\n"
+            "Set this if your mail host requires authenticated access."),
+        (BooleanOption, "tls", "no",
+            "If your SMTP mail host provides or requires TLS\n"
+            "(Transport Layer Security) then set this option to 'yes'."),
+        (NullableFilePathOption, "tls_keyfile", "",
+            "If TLS is used, you may set this option to the name\n"
+            "of a PEM formatted file that contains your private key."),
+        (NullableFilePathOption, "tls_certfile", "",
+            "If TLS is used, you may set this option to the name\n"
+            "of a PEM formatted certificate chain file."),
+        (Option, "charset", "utf-8",
+            "Character set to encode email headers with.\n"
+            "We use utf-8 by default, as it's the most flexible.\n"
+            "Some mail readers (eg. Eudora) can't cope with that,\n"
+            "so you might need to specify a more limited character set\n"
+            "(eg. iso-8859-1).",
+            ["EMAIL_CHARSET"]),
+        (FilePathOption, "debug", "",
+            "Setting this option makes Roundup to write all outgoing email\n"
+            "messages to this file *instead* of sending them.\n"
+            "This option has the same effect as environment variable"
+            " SENDMAILDEBUG.\nEnvironment variable takes precedence."),
+    ), "Outgoing email options.\nUsed for nozy messages and approval requests"),
+    ("mailgw", (
+        (BooleanOption, "keep_quoted_text", "yes",
+            "Keep email citations when accepting messages.\n"
+            "Setting this to \"no\" strips out \"quoted\" text"
+            " from the message.\n"
+            "Signatures are also stripped.",
+            ["EMAIL_KEEP_QUOTED_TEXT"]),
+        (BooleanOption, "leave_body_unchanged", "no",
+            "Preserve the email body as is - that is,\n"
+            "keep the citations _and_ signatures.",
+            ["EMAIL_LEAVE_BODY_UNCHANGED"]),
+        (Option, "default_class", "issue",
+            "Default class to use in the mailgw\n"
+            "if one isn't supplied in email subjects.\n"
+            "To disable, leave the value blank.",
+            ["MAIL_DEFAULT_CLASS"]),
+        (NullableOption, "language", "",
+            "Default locale name for the tracker mail gateway.\n"
+            "If this option is not set, mail gateway will use\n"
+            "the language of the tracker instance."),
+        (Option, "subject_prefix_parsing", "strict",
+            "Controls the parsing of the [prefix] on subject\n"
+            "lines in incoming emails. \"strict\" will return an\n"
+            "error to the sender if the [prefix] is not recognised.\n"
+            "\"loose\" will attempt to parse the [prefix] but just\n"
+            "pass it through as part of the issue title if not\n"
+            "recognised. \"none\" will always pass any [prefix]\n"
+            "through as part of the issue title."),
+        (Option, "subject_suffix_parsing", "strict",
+            "Controls the parsing of the [suffix] on subject\n"
+            "lines in incoming emails. \"strict\" will return an\n"
+            "error to the sender if the [suffix] is not recognised.\n"
+            "\"loose\" will attempt to parse the [suffix] but just\n"
+            "pass it through as part of the issue title if not\n"
+            "recognised. \"none\" will always pass any [suffix]\n"
+            "through as part of the issue title."),
+        (Option, "subject_suffix_delimiters", "[]",
+            "Defines the brackets used for delimiting the commands\n"
+            "suffix in a subject line."),
+        (Option, "subject_content_match", "always",
+            "Controls matching of the incoming email subject line\n"
+            "against issue titles in the case where there is no\n"
+            "designator [prefix]. \"never\" turns off matching.\n"
+            "\"creation + interval\" or \"activity + interval\"\n"
+            "will match an issue for the interval after the issue's\n"
+            "creation or last activity. The interval is a standard\n"
+            "Roundup interval."),
+    ), "Roundup Mail Gateway options"),
+    ("nosy", (
+        (RunDetectorOption, "messages_to_author", "no",
+            "Send nosy messages to the author of the message.",
+            ["MESSAGES_TO_AUTHOR"]),
+        (Option, "signature_position", "bottom",
+            "Where to place the email signature.\n"
+            "Allowed values: top, bottom, none",
+            ["EMAIL_SIGNATURE_POSITION"]),
+        (RunDetectorOption, "add_author", "new",
+            "Does the author of a message get placed on the nosy list\n"
+            "automatically?  If 'new' is used, then the author will\n"
+            "only be added when a message creates a new issue.\n"
+            "If 'yes', then the author will be added on followups too.\n"
+            "If 'no', they're never added to the nosy.\n",
+            ["ADD_AUTHOR_TO_NOSY"]),
+        (RunDetectorOption, "add_recipients", "new",
+            "Do the recipients (To:, Cc:) of a message get placed on the\n"
+            "nosy list?  If 'new' is used, then the recipients will\n"
+            "only be added when a message creates a new issue.\n"
+            "If 'yes', then the recipients will be added on followups too.\n"
+            "If 'no', they're never added to the nosy.\n",
+            ["ADD_RECIPIENTS_TO_NOSY"]),
+        (Option, "email_sending", "single",
+            "Controls the email sending from the nosy reactor. If\n"
+            "\"multiple\" then a separate email is sent to each\n"
+            "recipient. If \"single\" then a single email is sent with\n"
+            "each recipient as a CC address."),
+    ), "Nosy messages sending"),
+)
+
+### Configuration classes
+
+class Config:
+
+    """Base class for configuration objects.
+
+    Configuration options may be accessed as attributes or items
+    of instances of this class.  All option names are uppercased.
+
+    """
+
+    # Config file name
+    INI_FILE = "config.ini"
+
+    # Object attributes that should not be taken as common configuration
+    # options in __setattr__ (most of them are initialized in constructor):
+    # builtin pseudo-option - package home directory
+    HOME = "."
+    # names of .ini file sections, in order
+    sections = None
+    # section comments
+    section_descriptions = None
+    # lists of option names for each section, in order
+    section_options = None
+    # mapping from option names and aliases to Option instances
+    options = None
+    # actual name of the config file.  set on load.
+    filepath = os.path.join(HOME, INI_FILE)
+
+    def __init__(self, config_path=None, layout=None, settings={}):
+        """Initialize confing instance
+
+        Parameters:
+            config_path:
+                optional directory or file name of the config file.
+                If passed, load the config after processing layout (if any).
+                If config_path is a directory name, use default base name
+                of the config file.
+            layout:
+                optional configuration layout, a sequence of
+                section definitions suitable for .add_section()
+            settings:
+                optional setting overrides (dictionary).
+                The overrides are applied after loading config file.
+
+        """
+        # initialize option containers:
+        self.sections = []
+        self.section_descriptions = {}
+        self.section_options = {}
+        self.options = {}
+        # add options from the layout structure
+        if layout:
+            for section in layout:
+                self.add_section(*section)
+        if config_path is not None:
+            self.load(config_path)
+        for (name, value) in settings.items():
+            self[name.upper()] = value
+
+    def add_section(self, section, options, description=None):
+        """Define new config section
+
+        Parameters:
+            section - name of the config.ini section
+            options - a sequence of Option definitions.
+                Each Option definition is a sequence
+                containing class object and constructor
+                parameters, starting from the setting name:
+                setting, default, [description, [aliases]]
+            description - optional section comment
+
+        Note: aliases should only exist in historical options
+        for backwards compatibility - new options should
+        *not* have aliases!
+
+        """
+        if description or not self.section_descriptions.has_key(section):
+            self.section_descriptions[section] = description
+        for option_def in options:
+            klass = option_def[0]
+            args = option_def[1:]
+            option = klass(self, section, *args)
+            self.add_option(option)
+
+    def add_option(self, option):
+        """Adopt a new Option object"""
+        _section = option.section
+        _name = option.setting
+        if _section not in self.sections:
+            self.sections.append(_section)
+        _options = self._get_section_options(_section)
+        if _name not in _options:
+            _options.append(_name)
+        # (section, name) key is used for writing .ini file
+        self.options[(_section, _name)] = option
+        # make the option known under all of it's A.K.A.s
+        for _name in option.aliases:
+            self.options[_name] = option
+
+    def update_option(self, name, klass,
+        default=NODEFAULT, description=None
+    ):
+        """Override behaviour of early created option.
+
+        Parameters:
+            name:
+                option name
+            klass:
+                one of the Option classes
+            default:
+                optional default value for the option
+            description:
+                optional new description for the option
+
+        Conversion from current option value to new class value
+        is done via string representation.
+
+        This method may be used to attach some brains
+        to options autocreated by UserConfig.
+
+        """
+        # fetch current option
+        option = self._get_option(name)
+        # compute constructor parameters
+        if default is NODEFAULT:
+            default = option.default
+        if description is None:
+            description = option.description
+        value = option.value2str(current=1)
+        # resurrect the option
+        option = klass(self, option.section, option.setting,
+            default=default, description=description)
+        # apply the value
+        option.set(value)
+        # incorporate new option
+        del self[name]
+        self.add_option(option)
+
+    def reset(self):
+        """Set all options to their default values"""
+        for _option in self.items():
+            _option.reset()
+
+    # Meant for commandline tools.
+    # Allows automatic creation of configuration files like this:
+    #  roundup-server -p 8017 -u roundup --save-config
+    def getopt(self, args, short_options="", long_options=(),
+        config_load_options=("C", "config"), **options
+    ):
+        """Apply options specified in command line arguments.
+
+        Parameters:
+            args:
+                command line to parse (sys.argv[1:])
+            short_options:
+                optional string of letters for command line options
+                that are not config options
+            long_options:
+                optional list of names for long options
+                that are not config options
+            config_load_options:
+                two-element sequence (letter, long_option) defining
+                the options for config file.  If unset, don't load
+                config file; otherwise config file is read prior
+                to applying other options.  Short option letter
+                must not have a colon and long_option name must
+                not have an equal sign or '--' prefix.
+            options:
+                mapping from option names to command line option specs.
+                e.g. server_port="p:", server_user="u:"
+                Names are forced to lower case for commandline parsing
+                (long options) and to upper case to find config options.
+                Command line options accepting no value are assumed
+                to be binary and receive value 'yes'.
+
+        Return value: same as for python standard getopt(), except that
+        processed options are removed from returned option list.
+
+        """
+        # take a copy of long_options
+        long_options = list(long_options)
+        # build option lists
+        cfg_names = {}
+        booleans = []
+        for (name, letter) in options.items():
+            cfg_name = name.upper()
+            short_opt = "-" + letter[0]
+            name = name.lower().replace("_", "-")
+            cfg_names.update({short_opt: cfg_name, "--" + name: cfg_name})
+
+            short_options += letter
+            if letter[-1] == ":":
+                long_options.append(name + "=")
+            else:
+                booleans.append(short_opt)
+                long_options.append(name)
+
+        if config_load_options:
+            short_options += config_load_options[0] + ":"
+            long_options.append(config_load_options[1] + "=")
+            # compute names that will be searched in getopt return value
+            config_load_options = (
+                "-" + config_load_options[0],
+                "--" + config_load_options[1],
+            )
+        # parse command line arguments
+        optlist, args = getopt.getopt(args, short_options, long_options)
+        # load config file if requested
+        if config_load_options:
+            for option in optlist:
+                if option[0] in config_load_options:
+                    self.load_ini(option[1])
+                    optlist.remove(option)
+                    break
+        # apply options
+        extra_options = []
+        for (opt, arg) in optlist:
+            if (opt in booleans): # and not arg
+                arg = "yes"
+            try:
+                name = cfg_names[opt]
+            except KeyError:
+                extra_options.append((opt, arg))
+            else:
+                self[name] = arg
+        return (extra_options, args)
+
+    # option and section locators (used in option access methods)
+
+    def _get_option(self, name):
+        try:
+            return self.options[name]
+        except KeyError:
+            raise InvalidOptionError(name)
+
+    def _get_section_options(self, name):
+        return self.section_options.setdefault(name, [])
+
+    def _get_unset_options(self):
+        """Return options that need manual adjustments
+
+        Return value is a dictionary where keys are section
+        names and values are lists of option names as they
+        appear in the config file.
+
+        """
+        need_set = {}
+        for option in self.items():
+            if not option.isset():
+                need_set.setdefault(option.section, []).append(option.setting)
+        return need_set
+
+    def _adjust_options(self, config):
+        """Load ad-hoc option definitions from ConfigParser instance."""
+        pass
+
+    def _get_name(self):
+        """Return the service name for config file heading"""
+        return ""
+
+    # file operations
+
+    def load_ini(self, config_path, defaults=None):
+        """Set options from config.ini file in given home_dir
+
+        Parameters:
+            config_path:
+                directory or file name of the config file.
+                If config_path is a directory name, use default
+                base name of the config file
+            defaults:
+                optional dictionary of defaults for ConfigParser
+
+        Note: if home_dir does not contain config.ini file,
+        no error is raised.  Config will be reset to defaults.
+
+        """
+        if os.path.isdir(config_path):
+            home_dir = config_path
+            config_path = os.path.join(config_path, self.INI_FILE)
+        else:
+            home_dir = os.path.dirname(config_path)
+        # parse the file
+        config_defaults = {"HOME": home_dir}
+        if defaults:
+            config_defaults.update(defaults)
+        config = ConfigParser.ConfigParser(config_defaults)
+        config.read([config_path])
+        # .ini file loaded ok.
+        self.HOME = home_dir
+        self.filepath = config_path
+        self._adjust_options(config)
+        # set the options, starting from HOME
+        self.reset()
+        for option in self.items():
+            option.load_ini(config)
+
+    def load(self, home_dir):
+        """Load configuration settings from home_dir"""
+        self.load_ini(home_dir)
+
+    def save(self, ini_file=None):
+        """Write current configuration to .ini file
+
+        'ini_file' argument, if passed, must be valid full path
+        to the file to write.  If omitted, default file in current
+        HOME is created.
+
+        If the file to write already exists, it is saved with '.bak'
+        extension.
+
+        """
+        if ini_file is None:
+            ini_file = self.filepath
+        _tmp_file = os.path.splitext(ini_file)[0]
+        _bak_file = _tmp_file + ".bak"
+        _tmp_file = _tmp_file + ".tmp"
+        _fp = file(_tmp_file, "wt")
+        _fp.write("# %s configuration file\n" % self._get_name())
+        _fp.write("# Autogenerated at %s\n" % time.asctime())
+        need_set = self._get_unset_options()
+        if need_set:
+            _fp.write("\n# WARNING! Following options need adjustments:\n")
+            for section, options in need_set.items():
+                _fp.write("#  [%s]: %s\n" % (section, ", ".join(options)))
+        for section in self.sections:
+            comment = self.section_descriptions.get(section, None)
+            if comment:
+                _fp.write("\n# ".join([""] + comment.split("\n")) +"\n")
+            else:
+                # no section comment - just leave a blank line between sections
+                _fp.write("\n")
+            _fp.write("[%s]\n" % section)
+            for option in self._get_section_options(section):
+                _fp.write("\n" + self.options[(section, option)].format())
+        _fp.close()
+        if os.access(ini_file, os.F_OK):
+            if os.access(_bak_file, os.F_OK):
+                os.remove(_bak_file)
+            os.rename(ini_file, _bak_file)
+        os.rename(_tmp_file, ini_file)
+
+    # container emulation
+
+    def __len__(self):
+        return len(self.items())
+
+    def __getitem__(self, name):
+        if name == "HOME":
+            return self.HOME
+        else:
+            return self._get_option(name).get()
+
+    def __setitem__(self, name, value):
+        if name == "HOME":
+            self.HOME = value
+        else:
+            self._get_option(name).set(value)
+
+    def __delitem__(self, name):
+        _option = self._get_option(name)
+        _section = _option.section
+        _name = _option.setting
+        self._get_section_options(_section).remove(_name)
+        del self.options[(_section, _name)]
+        for _alias in _option.aliases:
+            del self.options[_alias]
+
+    def items(self):
+        """Return the list of Option objects, in .ini file order
+
+        Note that HOME is not included in this list
+        because it is builtin pseudo-option, not a real Option
+        object loaded from or saved to .ini file.
+
+        """
+        return [self.options[(_section, _name)]
+            for _section in self.sections
+            for _name in self._get_section_options(_section)
+        ]
+
+    def keys(self):
+        """Return the list of "canonical" names of the options
+
+        Unlike .items(), this list also includes HOME
+
+        """
+        return ["HOME"] + [_option.name for _option in self.items()]
+
+    # .values() is not implemented because i am not sure what should be
+    # the values returned from this method: Option instances or config values?
+
+    # attribute emulation
+
+    def __setattr__(self, name, value):
+        if self.__dict__.has_key(name) or hasattr(self.__class__, name):
+            self.__dict__[name] = value
+        else:
+            self._get_option(name).set(value)
+
+    # Note: __getattr__ is not symmetric to __setattr__:
+    #   self.__dict__ lookup is done before calling this method
+    def __getattr__(self, name):
+        return self[name]
+
+class UserConfig(Config):
+
+    """Configuration for user extensions.
+
+    Instances of this class have no predefined configuration layout.
+    Options are created on the fly for each setting present in the
+    config file.
+
+    """
+
+    def _adjust_options(self, config):
+        # config defaults appear in all sections.
+        # we'll need to filter them out.
+        defaults = config.defaults().keys()
+        # see what options are already defined and add missing ones
+        preset = [(option.section, option.setting) for option in self.items()]
+        for section in config.sections():
+            for name in config.options(section):
+                if ((section, name) not in preset) \
+                and (name not in defaults):
+                    self.add_option(Option(self, section, name))
+
+class CoreConfig(Config):
+
+    """Roundup instance configuration.
+
+    Core config has a predefined layout (see the SETTINGS structure),
+    supports loading of old-style pythonic configurations and holds
+    three additional attributes:
+        logging:
+            instance logging engine, from standard python logging module
+            or minimalistic logger implemented in Roundup
+        detectors:
+            user-defined configuration for detectors
+        ext:
+            user-defined configuration for extensions
+
+    """
+
+    # module name for old style configuration
+    PYCONFIG = "config"
+    # user configs
+    ext = None
+    detectors = None
+
+    def __init__(self, home_dir=None, settings={}):
+        Config.__init__(self, home_dir, layout=SETTINGS, settings=settings)
+        # load the config if home_dir given
+        if home_dir is None:
+            self.init_logging()
+
+    def _get_unset_options(self):
+        need_set = Config._get_unset_options(self)
+        # remove MAIL_PASSWORD if MAIL_USER is empty
+        if "password" in need_set.get("mail", []):
+            if not self["MAIL_USERNAME"]:
+                settings = need_set["mail"]
+                settings.remove("password")
+                if not settings:
+                    del need_set["mail"]
+        return need_set
+
+    def _get_name(self):
+        return self["TRACKER_NAME"]
+
+    def reset(self):
+        Config.reset(self)
+        if self.ext:
+            self.ext.reset()
+        if self.detectors:
+            self.detectors.reset()
+        self.init_logging()
+
+    def init_logging(self):
+        _file = self["LOGGING_CONFIG"]
+        if _file and os.path.isfile(_file):
+            logging.config.fileConfig(_file)
+            return
+
+        _file = self["LOGGING_FILENAME"]
+        # set file & level on the root logger
+        logger = logging.getLogger()
+        if _file:
+            hdlr = logging.FileHandler(_file)
+        else:
+            hdlr = logging.StreamHandler(sys.stdout)
+        formatter = logging.Formatter(
+            '%(asctime)s %(levelname)s %(message)s')
+        hdlr.setFormatter(formatter)
+        # no logging API to remove all existing handlers!?!
+        logger.handlers = [hdlr]
+        logger.setLevel(logging._levelNames[self["LOGGING_LEVEL"] or "ERROR"])
+
+    def load(self, home_dir):
+        """Load configuration from path designated by home_dir argument"""
+        if os.path.isfile(os.path.join(home_dir, self.INI_FILE)):
+            self.load_ini(home_dir)
+        else:
+            self.load_pyconfig(home_dir)
+        self.init_logging()
+        self.ext = UserConfig(os.path.join(home_dir, "extensions"))
+        self.detectors = UserConfig(os.path.join(home_dir, "detectors"))
+
+    def load_ini(self, home_dir, defaults=None):
+        """Set options from config.ini file in given home_dir directory"""
+        config_defaults = {"TRACKER_HOME": home_dir}
+        if defaults:
+            config_defaults.update(defaults)
+        Config.load_ini(self, home_dir, config_defaults)
+
+    def load_pyconfig(self, home_dir):
+        """Set options from config.py file in given home_dir directory"""
+        # try to locate and import the module
+        _mod_fp = None
+        try:
+            try:
+                _module = imp.find_module(self.PYCONFIG, [home_dir])
+                _mod_fp = _module[0]
+                _config = imp.load_module(self.PYCONFIG, *_module)
+            except ImportError:
+                raise NoConfigError(home_dir)
+        finally:
+            if _mod_fp is not None:
+                _mod_fp.close()
+        # module loaded ok.  set the options, starting from HOME
+        self.reset()
+        self.HOME = home_dir
+        for _option in self.items():
+            _option.load_pyconfig(_config)
+        # backward compatibility:
+        # SMTP login parameters were specified as a tuple in old style configs
+        # convert them to new plain string options
+        _mailuser = getattr(_config, "MAILUSER", ())
+        if len(_mailuser) > 0:
+            self.MAIL_USERNAME = _mailuser[0]
+        if len(_mailuser) > 1:
+            self.MAIL_PASSWORD = _mailuser[1]
+
+    # in this config, HOME is also known as TRACKER_HOME
+    def __getitem__(self, name):
+        if name == "TRACKER_HOME":
+            return self.HOME
+        else:
+            return Config.__getitem__(self, name)
+
+    def __setitem__(self, name, value):
+        if name == "TRACKER_HOME":
+            self.HOME = value
+        else:
+            self._get_option(name).set(value)
+
+    def __setattr__(self, name, value):
+        if name == "TRACKER_HOME":
+            self.__dict__["HOME"] = value
+        else:
+            Config.__setattr__(self, name, value)
+
+# vim: set et sts=4 sw=4 :

Added: tracker/vendor/roundup/current/roundup/date.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/date.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,950 @@
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+#
+# $Id: date.py,v 1.86 2006/04/27 05:15:16 richard Exp $
+
+"""Date, time and time interval handling.
+"""
+__docformat__ = 'restructuredtext'
+
+import time, re, calendar
+import i18n
+
+try:
+    import datetime
+    have_datetime = 1
+except:
+    have_datetime = 0
+
+def _add_granularity(src, order, value = 1):
+    '''Increment first non-None value in src dictionary ordered by 'order'
+    parameter
+    '''
+    for gran in order:
+        if src[gran]:
+            src[gran] = int(src[gran]) + value
+            break
+
+# no, I don't know why we must anchor the date RE when we only ever use it
+# in a match()
+date_re = re.compile(r'''^
+    ((?P<y>\d\d\d\d)([/-](?P<m>\d\d?)([/-](?P<d>\d\d?))?)? # yyyy[-mm[-dd]]
+    |(?P<a>\d\d?)[/-](?P<b>\d\d?))?              # or mm-dd
+    (?P<n>\.)?                                   # .
+    (((?P<H>\d?\d):(?P<M>\d\d))?(:(?P<S>\d\d?(\.\d+)?))?)?  # hh:mm:ss
+    (?P<o>[\d\smywd\-+]+)?                       # offset
+$''', re.VERBOSE)
+serialised_date_re = re.compile(r'''
+    (\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d?(\.\d+)?)
+''', re.VERBOSE)
+
+class Date:
+    '''
+    As strings, date-and-time stamps are specified with the date in
+    international standard format (yyyy-mm-dd) joined to the time
+    (hh:mm:ss) by a period ("."). Dates in this form can be easily compared
+    and are fairly readable when printed. An example of a valid stamp is
+    "2000-06-24.13:03:59". We'll call this the "full date format". When
+    Timestamp objects are printed as strings, they appear in the full date
+    format with the time always given in GMT. The full date format is
+    always exactly 19 characters long.
+
+    For user input, some partial forms are also permitted: the whole time
+    or just the seconds may be omitted; and the whole date may be omitted
+    or just the year may be omitted. If the time is given, the time is
+    interpreted in the user's local time zone. The Date constructor takes
+    care of these conversions. In the following examples, suppose that yyyy
+    is the current year, mm is the current month, and dd is the current day
+    of the month; and suppose that the user is on Eastern Standard Time.
+    Examples::
+
+      "2000-04-17" means <Date 2000-04-17.00:00:00>
+      "01-25" means <Date yyyy-01-25.00:00:00>
+      "2000-04-17.03:45" means <Date 2000-04-17.08:45:00>
+      "08-13.22:13" means <Date yyyy-08-14.03:13:00>
+      "11-07.09:32:43" means <Date yyyy-11-07.14:32:43>
+      "14:25" means <Date yyyy-mm-dd.19:25:00>
+      "8:47:11" means <Date yyyy-mm-dd.13:47:11>
+      "2003" means <Date 2003-01-01.00:00:00>
+      "2003-06" means <Date 2003-06-01.00:00:00>
+      "." means "right now"
+
+    The Date class should understand simple date expressions of the form
+    stamp + interval and stamp - interval. When adding or subtracting
+    intervals involving months or years, the components are handled
+    separately. For example, when evaluating "2000-06-25 + 1m 10d", we
+    first add one month to get 2000-07-25, then add 10 days to get
+    2000-08-04 (rather than trying to decide whether 1m 10d means 38 or 40
+    or 41 days).  Example usage::
+
+        >>> Date(".")
+        <Date 2000-06-26.00:34:02>
+        >>> _.local(-5)
+        "2000-06-25.19:34:02"
+        >>> Date(". + 2d")
+        <Date 2000-06-28.00:34:02>
+        >>> Date("1997-04-17", -5)
+        <Date 1997-04-17.00:00:00>
+        >>> Date("01-25", -5)
+        <Date 2000-01-25.00:00:00>
+        >>> Date("08-13.22:13", -5)
+        <Date 2000-08-14.03:13:00>
+        >>> Date("14:25", -5)
+        <Date 2000-06-25.19:25:00>
+
+    The date format 'yyyymmddHHMMSS' (year, month, day, hour,
+    minute, second) is the serialisation format returned by the serialise()
+    method, and is accepted as an argument on instatiation.
+
+    The date class handles basic arithmetic::
+
+        >>> d1=Date('.')
+        >>> d1
+        <Date 2004-04-06.22:04:20.766830>
+        >>> d2=Date('2003-07-01')
+        >>> d2
+        <Date 2003-07-01.00:00:0.000000>
+        >>> d1-d2
+        <Interval + 280d 22:04:20>
+        >>> i1=_
+        >>> d2+i1
+        <Date 2004-04-06.22:04:20.000000>
+        >>> d1-i1
+        <Date 2003-07-01.00:00:0.000000>
+    '''
+
+    def __init__(self, spec='.', offset=0, add_granularity=0, translator=i18n):
+        """Construct a date given a specification and a time zone offset.
+
+        'spec'
+           is a full date or a partial form, with an optional added or
+           subtracted interval. Or a date 9-tuple.
+        'offset'
+           is the local time zone offset from GMT in hours.
+        'translator'
+           is i18n module or one of gettext translation classes.
+           It must have attributes 'gettext' and 'ngettext',
+           serving as translation functions.
+        """
+        self.setTranslator(translator)
+        if type(spec) == type(''):
+            self.set(spec, offset=offset, add_granularity=add_granularity)
+            return
+        elif have_datetime and isinstance(spec, datetime.datetime):
+            # Python 2.3+ datetime object
+            y,m,d,H,M,S,x,x,x = spec.timetuple()
+            if y < 1970: raise ValueError, 'year must be > 1970'
+            S += spec.microsecond/1000000.
+            spec = (y,m,d,H,M,S,x,x,x)
+        elif hasattr(spec, 'tuple'):
+            spec = spec.tuple()
+        elif isinstance(spec, Date):
+            spec = spec.get_tuple()
+        try:
+            y,m,d,H,M,S,x,x,x = spec
+            if y < 1970: raise ValueError, 'year must be > 1970'
+            frac = S - int(S)
+            ts = calendar.timegm((y,m,d,H+offset,M,S,0,0,0))
+            self.year, self.month, self.day, self.hour, self.minute, \
+                self.second, x, x, x = time.gmtime(ts)
+            # we lost the fractional part
+            self.second = self.second + frac
+        except:
+            raise ValueError, 'Unknown spec %r' % (spec,)
+
+    def set(self, spec, offset=0, date_re=date_re,
+            serialised_re=serialised_date_re, add_granularity=0):
+        ''' set the date to the value in spec
+        '''
+
+        m = serialised_re.match(spec)
+        if m is not None:
+            # we're serialised - easy!
+            g = m.groups()
+            (self.year, self.month, self.day, self.hour, self.minute) = \
+                map(int, g[:5])
+            self.second = float(g[5])
+            return
+
+        # not serialised data, try usual format
+        m = date_re.match(spec)
+        if m is None:
+            raise ValueError, self._('Not a date spec: '
+                '"yyyy-mm-dd", "mm-dd", "HH:MM", "HH:MM:SS" or '
+                '"yyyy-mm-dd.HH:MM:SS.SSS"')
+
+        info = m.groupdict()
+
+        if add_granularity:
+            _add_granularity(info, 'SMHdmyab')
+
+        # get the current date as our default
+        ts = time.time()
+        frac = ts - int(ts)
+        y,m,d,H,M,S,x,x,x = time.gmtime(ts)
+        # gmtime loses the fractional seconds
+        S = S + frac
+
+        if info['y'] is not None or info['a'] is not None:
+            if info['y'] is not None:
+                y = int(info['y'])
+                if y < 1970: raise ValueError, 'year must be > 1970'
+                m,d = (1,1)
+                if info['m'] is not None:
+                    m = int(info['m'])
+                    if info['d'] is not None:
+                        d = int(info['d'])
+            if info['a'] is not None:
+                m = int(info['a'])
+                d = int(info['b'])
+            H = -offset
+            M = S = 0
+
+        # override hour, minute, second parts
+        if info['H'] is not None and info['M'] is not None:
+            H = int(info['H']) - offset
+            M = int(info['M'])
+            S = 0
+            if info['S'] is not None:
+                S = float(info['S'])
+
+        if add_granularity:
+            S = S - 1
+
+        # now handle the adjustment of hour
+        frac = S - int(S)
+        ts = calendar.timegm((y,m,d,H,M,S,0,0,0))
+        self.year, self.month, self.day, self.hour, self.minute, \
+            self.second, x, x, x = time.gmtime(ts)
+        # we lost the fractional part along the way
+        self.second = self.second + frac
+
+        if info.get('o', None):
+            try:
+                self.applyInterval(Interval(info['o'], allowdate=0))
+            except ValueError:
+                raise ValueError, self._('%r not a date / time spec '
+                    '"yyyy-mm-dd", "mm-dd", "HH:MM", "HH:MM:SS" or '
+                    '"yyyy-mm-dd.HH:MM:SS.SSS"')%(spec,)
+
+    def addInterval(self, interval):
+        ''' Add the interval to this date, returning the date tuple
+        '''
+        # do the basic calc
+        sign = interval.sign
+        year = self.year + sign * interval.year
+        month = self.month + sign * interval.month
+        day = self.day + sign * interval.day
+        hour = self.hour + sign * interval.hour
+        minute = self.minute + sign * interval.minute
+        # Intervals work on whole seconds
+        second = int(self.second) + sign * interval.second
+
+        # now cope with under- and over-flow
+        # first do the time
+        while (second < 0 or second > 59 or minute < 0 or minute > 59 or
+                hour < 0 or hour > 23):
+            if second < 0: minute -= 1; second += 60
+            elif second > 59: minute += 1; second -= 60
+            if minute < 0: hour -= 1; minute += 60
+            elif minute > 59: hour += 1; minute -= 60
+            if hour < 0: day -= 1; hour += 24
+            elif hour > 23: day += 1; hour -= 24
+
+        # fix up the month so we're within range
+        while month < 1 or month > 12:
+            if month < 1: year -= 1; month += 12
+            if month > 12: year += 1; month -= 12
+
+        # now do the days, now that we know what month we're in
+        def get_mdays(year, month):
+            if month == 2 and calendar.isleap(year): return 29
+            else: return calendar.mdays[month]
+
+        while month < 1 or month > 12 or day < 1 or day > get_mdays(year,month):
+            # now to day under/over
+            if day < 1:
+                # When going backwards, decrement month, then increment days
+                month -= 1
+                day += get_mdays(year,month)
+            elif day > get_mdays(year,month):
+                # When going forwards, decrement days, then increment month
+                day -= get_mdays(year,month)
+                month += 1
+
+            # possibly fix up the month so we're within range
+            while month < 1 or month > 12:
+                if month < 1: year -= 1; month += 12 ; day += 31
+                if month > 12: year += 1; month -= 12
+
+        return (year, month, day, hour, minute, second, 0, 0, 0)
+
+    def differenceDate(self, other):
+        "Return the difference between this date and another date"
+        return self - other
+
+    def applyInterval(self, interval):
+        ''' Apply the interval to this date
+        '''
+        self.year, self.month, self.day, self.hour, self.minute, \
+            self.second, x, x, x = self.addInterval(interval)
+
+    def __add__(self, interval):
+        """Add an interval to this date to produce another date.
+        """
+        return Date(self.addInterval(interval), translator=self.translator)
+
+    # deviates from spec to allow subtraction of dates as well
+    def __sub__(self, other):
+        """ Subtract:
+             1. an interval from this date to produce another date.
+             2. a date from this date to produce an interval.
+        """
+        if isinstance(other, Interval):
+            other = Interval(other.get_tuple())
+            other.sign *= -1
+            return self.__add__(other)
+
+        assert isinstance(other, Date), 'May only subtract Dates or Intervals'
+
+        return self.dateDelta(other)
+
+    def dateDelta(self, other):
+        """ Produce an Interval of the difference between this date
+            and another date. Only returns days:hours:minutes:seconds.
+        """
+        # Returning intervals larger than a day is almost
+        # impossible - months, years, weeks, are all so imprecise.
+        a = calendar.timegm((self.year, self.month, self.day, self.hour,
+            self.minute, self.second, 0, 0, 0))
+        b = calendar.timegm((other.year, other.month, other.day,
+            other.hour, other.minute, other.second, 0, 0, 0))
+        # intervals work in whole seconds
+        diff = int(a - b)
+        if diff > 0:
+            sign = 1
+        else:
+            sign = -1
+            diff = -diff
+        S = diff%60
+        M = (diff/60)%60
+        H = (diff/(60*60))%24
+        d = diff/(24*60*60)
+        return Interval((0, 0, d, H, M, S), sign=sign,
+            translator=self.translator)
+
+    def __cmp__(self, other, int_seconds=0):
+        """Compare this date to another date."""
+        if other is None:
+            return 1
+        for attr in ('year', 'month', 'day', 'hour', 'minute'):
+            if not hasattr(other, attr):
+                return 1
+            r = cmp(getattr(self, attr), getattr(other, attr))
+            if r: return r
+        if not hasattr(other, 'second'):
+            return 1
+        if int_seconds:
+            return cmp(int(self.second), int(other.second))
+        return cmp(self.second, other.second)
+
+    def __str__(self):
+        """Return this date as a string in the yyyy-mm-dd.hh:mm:ss format."""
+        return self.formal()
+
+    def formal(self, sep='.', sec='%02d'):
+        f = '%%4d-%%02d-%%02d%s%%02d:%%02d:%s'%(sep, sec)
+        return f%(self.year, self.month, self.day, self.hour, self.minute,
+            self.second)
+
+    def pretty(self, format='%d %B %Y'):
+        ''' print up the date date using a pretty format...
+
+            Note that if the day is zero, and the day appears first in the
+            format, then the day number will be removed from output.
+        '''
+        # Python2.4 strftime() enforces the non-zero-ness of the day-of-year
+        # component of the time tuple, so we need to figure it out
+        t = (self.year, self.month, self.day, self.hour, self.minute,
+            int(self.second), 0, 0, 0)
+        t = calendar.timegm(t)
+        t = time.gmtime(t)
+        str = time.strftime(format, t)
+
+        # handle zero day by removing it
+        if format.startswith('%d') and str[0] == '0':
+            return ' ' + str[1:]
+        return str
+
+    def __repr__(self):
+        return '<Date %s>'%self.formal(sec='%06.3f')
+
+    def local(self, offset):
+        """ Return this date as yyyy-mm-dd.hh:mm:ss in a local time zone.
+        """
+        return Date((self.year, self.month, self.day, self.hour + offset,
+            self.minute, self.second, 0, 0, 0), translator=self.translator)
+
+    def __deepcopy__(self, memo):
+        return Date((self.year, self.month, self.day, self.hour,
+            self.minute, self.second, 0, 0, 0), translator=self.translator)
+
+    def get_tuple(self):
+        return (self.year, self.month, self.day, self.hour, self.minute,
+            self.second, 0, 0, 0)
+
+    def serialise(self):
+        return '%4d%02d%02d%02d%02d%06.3f'%(self.year, self.month,
+            self.day, self.hour, self.minute, self.second)
+
+    def timestamp(self):
+        ''' return a UNIX timestamp for this date '''
+        frac = self.second - int(self.second)
+        ts = calendar.timegm((self.year, self.month, self.day, self.hour,
+            self.minute, self.second, 0, 0, 0))
+        # we lose the fractional part
+        return ts + frac
+
+    def setTranslator(self, translator):
+        """Replace the translation engine
+
+        'translator'
+           is i18n module or one of gettext translation classes.
+           It must have attributes 'gettext' and 'ngettext',
+           serving as translation functions.
+        """
+        self.translator = translator
+        self._ = translator.gettext
+        self.ngettext = translator.ngettext
+
+class Interval:
+    '''
+    Date intervals are specified using the suffixes "y", "m", and "d". The
+    suffix "w" (for "week") means 7 days. Time intervals are specified in
+    hh:mm:ss format (the seconds may be omitted, but the hours and minutes
+    may not).
+
+      "3y" means three years
+      "2y 1m" means two years and one month
+      "1m 25d" means one month and 25 days
+      "2w 3d" means two weeks and three days
+      "1d 2:50" means one day, two hours, and 50 minutes
+      "14:00" means 14 hours
+      "0:04:33" means four minutes and 33 seconds
+
+    Example usage:
+        >>> Interval("  3w  1  d  2:00")
+        <Interval + 22d 2:00>
+        >>> Date(". + 2d") + Interval("- 3w")
+        <Date 2000-06-07.00:34:02>
+        >>> Interval('1:59:59') + Interval('00:00:01')
+        <Interval + 2:00>
+        >>> Interval('2:00') + Interval('- 00:00:01')
+        <Interval + 1:59:59>
+        >>> Interval('1y')/2
+        <Interval + 6m>
+        >>> Interval('1:00')/2
+        <Interval + 0:30>
+        >>> Interval('2003-03-18')
+        <Interval + [number of days between now and 2003-03-18]>
+        >>> Interval('-4d 2003-03-18')
+        <Interval + [number of days between now and 2003-03-14]>
+
+    Interval arithmetic is handled in a couple of special ways, trying
+    to cater for the most common cases. Fundamentally, Intervals which
+    have both date and time parts will result in strange results in
+    arithmetic - because of the impossibility of handling day->month->year
+    over- and under-flows. Intervals may also be divided by some number.
+
+    Intervals are added to Dates in order of:
+       seconds, minutes, hours, years, months, days
+
+    Calculations involving months (eg '+2m') have no effect on days - only
+    days (or over/underflow from hours/mins/secs) will do that, and
+    days-per-month and leap years are accounted for. Leap seconds are not.
+
+    The interval format 'syyyymmddHHMMSS' (sign, year, month, day, hour,
+    minute, second) is the serialisation format returned by the serialise()
+    method, and is accepted as an argument on instatiation.
+
+    TODO: more examples, showing the order of addition operation
+    '''
+    def __init__(self, spec, sign=1, allowdate=1, add_granularity=0,
+        translator=i18n
+    ):
+        """Construct an interval given a specification."""
+        self.setTranslator(translator)
+        if isinstance(spec, (int, float, long)):
+            self.from_seconds(spec)
+        elif isinstance(spec, basestring):
+            self.set(spec, allowdate=allowdate, add_granularity=add_granularity)
+        elif isinstance(spec, Interval):
+            (self.sign, self.year, self.month, self.day, self.hour,
+                self.minute, self.second) = spec.get_tuple()
+        else:
+            if len(spec) == 7:
+                self.sign, self.year, self.month, self.day, self.hour, \
+                    self.minute, self.second = spec
+                self.second = int(self.second)
+            else:
+                # old, buggy spec form
+                self.sign = sign
+                self.year, self.month, self.day, self.hour, self.minute, \
+                    self.second = spec
+                self.second = int(self.second)
+
+    def __deepcopy__(self, memo):
+        return Interval((self.sign, self.year, self.month, self.day,
+            self.hour, self.minute, self.second), translator=self.translator)
+
+    def set(self, spec, allowdate=1, interval_re=re.compile('''
+            \s*(?P<s>[-+])?         # + or -
+            \s*((?P<y>\d+\s*)y)?    # year
+            \s*((?P<m>\d+\s*)m)?    # month
+            \s*((?P<w>\d+\s*)w)?    # week
+            \s*((?P<d>\d+\s*)d)?    # day
+            \s*(((?P<H>\d+):(?P<M>\d+))?(:(?P<S>\d+))?)?   # time
+            \s*(?P<D>
+                 (\d\d\d\d[/-])?(\d\d?)?[/-](\d\d?)?       # [yyyy-]mm-dd
+                 \.?                                       # .
+                 (\d?\d:\d\d)?(:\d\d)?                     # hh:mm:ss
+               )?''', re.VERBOSE), serialised_re=re.compile('''
+            (?P<s>[+-])?1?(?P<y>([ ]{3}\d|\d{4}))(?P<m>\d{2})(?P<d>\d{2})
+            (?P<H>\d{2})(?P<M>\d{2})(?P<S>\d{2})''', re.VERBOSE),
+            add_granularity=0):
+        ''' set the date to the value in spec
+        '''
+        self.year = self.month = self.week = self.day = self.hour = \
+            self.minute = self.second = 0
+        self.sign = 1
+        m = serialised_re.match(spec)
+        if not m:
+            m = interval_re.match(spec)
+            if not m:
+                raise ValueError, self._('Not an interval spec:'
+                    ' [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS] [date spec]')
+        else:
+            allowdate = 0
+
+        # pull out all the info specified
+        info = m.groupdict()
+        if add_granularity:
+            _add_granularity(info, 'SMHdwmy', (info['s']=='-' and -1 or 1))
+
+        valid = 0
+        for group, attr in {'y':'year', 'm':'month', 'w':'week', 'd':'day',
+                'H':'hour', 'M':'minute', 'S':'second'}.items():
+            if info.get(group, None) is not None:
+                valid = 1
+                setattr(self, attr, int(info[group]))
+
+        # make sure it's valid
+        if not valid and not info['D']:
+            raise ValueError, self._('Not an interval spec:'
+                ' [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS]')
+
+        if self.week:
+            self.day = self.day + self.week*7
+
+        if info['s'] is not None:
+            self.sign = {'+':1, '-':-1}[info['s']]
+
+        # use a date spec if one is given
+        if allowdate and info['D'] is not None:
+            now = Date('.')
+            date = Date(info['D'])
+            # if no time part was specified, nuke it in the "now" date
+            if not date.hour or date.minute or date.second:
+                now.hour = now.minute = now.second = 0
+            if date != now:
+                y = now - (date + self)
+                self.__init__(y.get_tuple())
+
+    def __cmp__(self, other):
+        """Compare this interval to another interval."""
+        if other is None:
+            # we are always larger than None
+            return 1
+        for attr in 'sign year month day hour minute second'.split():
+            r = cmp(getattr(self, attr), getattr(other, attr))
+            if r:
+                return r
+        return 0
+
+    def __str__(self):
+        """Return this interval as a string."""
+        l = []
+        if self.year: l.append('%sy'%self.year)
+        if self.month: l.append('%sm'%self.month)
+        if self.day: l.append('%sd'%self.day)
+        if self.second:
+            l.append('%d:%02d:%02d'%(self.hour, self.minute, self.second))
+        elif self.hour or self.minute:
+            l.append('%d:%02d'%(self.hour, self.minute))
+        if l:
+            l.insert(0, {1:'+', -1:'-'}[self.sign])
+        else:
+            l.append('00:00')
+        return ' '.join(l)
+
+    def __add__(self, other):
+        if isinstance(other, Date):
+            # the other is a Date - produce a Date
+            return Date(other.addInterval(self), translator=self.translator)
+        elif isinstance(other, Interval):
+            # add the other Interval to this one
+            a = self.get_tuple()
+            as = a[0]
+            b = other.get_tuple()
+            bs = b[0]
+            i = [as*x + bs*y for x,y in zip(a[1:],b[1:])]
+            i.insert(0, 1)
+            i = fixTimeOverflow(i)
+            return Interval(i, translator=self.translator)
+        # nope, no idea what to do with this other...
+        raise TypeError, "Can't add %r"%other
+
+    def __sub__(self, other):
+        if isinstance(other, Date):
+            # the other is a Date - produce a Date
+            interval = Interval(self.get_tuple())
+            interval.sign *= -1
+            return Date(other.addInterval(interval),
+                translator=self.translator)
+        elif isinstance(other, Interval):
+            # add the other Interval to this one
+            a = self.get_tuple()
+            as = a[0]
+            b = other.get_tuple()
+            bs = b[0]
+            i = [as*x - bs*y for x,y in zip(a[1:],b[1:])]
+            i.insert(0, 1)
+            i = fixTimeOverflow(i)
+            return Interval(i, translator=self.translator)
+        # nope, no idea what to do with this other...
+        raise TypeError, "Can't add %r"%other
+
+    def __div__(self, other):
+        """ Divide this interval by an int value.
+
+            Can't divide years and months sensibly in the _same_
+            calculation as days/time, so raise an error in that situation.
+        """
+        try:
+            other = float(other)
+        except TypeError:
+            raise ValueError, "Can only divide Intervals by numbers"
+
+        y, m, d, H, M, S = (self.year, self.month, self.day,
+            self.hour, self.minute, self.second)
+        if y or m:
+            if d or H or M or S:
+                raise ValueError, "Can't divide Interval with date and time"
+            months = self.year*12 + self.month
+            months *= self.sign
+
+            months = int(months/other)
+
+            sign = months<0 and -1 or 1
+            m = months%12
+            y = months / 12
+            return Interval((sign, y, m, 0, 0, 0, 0),
+                translator=self.translator)
+
+        else:
+            # handle a day/time division
+            seconds = S + M*60 + H*60*60 + d*60*60*24
+            seconds *= self.sign
+
+            seconds = int(seconds/other)
+
+            sign = seconds<0 and -1 or 1
+            seconds *= sign
+            S = seconds%60
+            seconds /= 60
+            M = seconds%60
+            seconds /= 60
+            H = seconds%24
+            d = seconds / 24
+            return Interval((sign, 0, 0, d, H, M, S),
+                translator=self.translator)
+
+    def __repr__(self):
+        return '<Interval %s>'%self.__str__()
+
+    def pretty(self):
+        ''' print up the date date using one of these nice formats..
+        '''
+        _quarters = self.minute / 15
+        if self.year:
+            s = self.ngettext("%(number)s year", "%(number)s years",
+                self.year) % {'number': self.year}
+        elif self.month or self.day > 28:
+            _months = max(1, int(((self.month * 30) + self.day) / 30))
+            s = self.ngettext("%(number)s month", "%(number)s months",
+                _months) % {'number': _months}
+        elif self.day > 7:
+            _weeks = int(self.day / 7)
+            s = self.ngettext("%(number)s week", "%(number)s weeks",
+                _weeks) % {'number': _weeks}
+        elif self.day > 1:
+            # Note: singular form is not used
+            s = self.ngettext('%(number)s day', '%(number)s days',
+                self.day) % {'number': self.day}
+        elif self.day == 1 or self.hour > 12:
+            if self.sign > 0:
+                return self._('tomorrow')
+            else:
+                return self._('yesterday')
+        elif self.hour > 1:
+            # Note: singular form is not used
+            s = self.ngettext('%(number)s hour', '%(number)s hours',
+                self.hour) % {'number': self.hour}
+        elif self.hour == 1:
+            if self.minute < 15:
+                s = self._('an hour')
+            elif _quarters == 2:
+                s = self._('1 1/2 hours')
+            else:
+                s = self.ngettext('1 %(number)s/4 hours',
+                    '1 %(number)s/4 hours', _quarters)%{'number': _quarters}
+        elif self.minute < 1:
+            if self.sign > 0:
+                return self._('in a moment')
+            else:
+                return self._('just now')
+        elif self.minute == 1:
+            # Note: used in expressions "in 1 minute" or "1 minute ago"
+            s = self._('1 minute')
+        elif self.minute < 15:
+            # Note: used in expressions "in 2 minutes" or "2 minutes ago"
+            s = self.ngettext('%(number)s minute', '%(number)s minutes',
+                self.minute) % {'number': self.minute}
+        elif _quarters == 2:
+            s = self._('1/2 an hour')
+        else:
+            s = self.ngettext('%(number)s/4 hour', '%(number)s/4 hours',
+                _quarters) % {'number': _quarters}
+        # XXX this is internationally broken
+        if self.sign < 0:
+            s = self._('%s ago') % s
+        else:
+            s = self._('in %s') % s
+        return s
+
+    def get_tuple(self):
+        return (self.sign, self.year, self.month, self.day, self.hour,
+            self.minute, self.second)
+
+    def serialise(self):
+        sign = self.sign > 0 and '+' or '-'
+        return '%s%04d%02d%02d%02d%02d%02d'%(sign, self.year, self.month,
+            self.day, self.hour, self.minute, self.second)
+
+    def as_seconds(self):
+        '''Calculate the Interval as a number of seconds.
+
+        Months are counted as 30 days, years as 365 days. Returns a Long
+        int.
+        '''
+        n = self.year * 365L
+        n = n + self.month * 30
+        n = n + self.day
+        n = n * 24
+        n = n + self.hour
+        n = n * 60
+        n = n + self.minute
+        n = n * 60
+        n = n + self.second
+        return n * self.sign
+
+    def from_seconds(self, val):
+        '''Figure my second, minute, hour and day values using a seconds
+        value.
+        '''
+        val = int(val)
+        if val < 0:
+            self.sign = -1
+            val = -val
+        else:
+            self.sign = 1
+        self.second = val % 60
+        val = val / 60
+        self.minute = val % 60
+        val = val / 60
+        self.hour = val % 24
+        val = val / 24
+        self.day = val
+        self.month = self.year = 0
+
+    def setTranslator(self, translator):
+        """Replace the translation engine
+
+        'translator'
+           is i18n module or one of gettext translation classes.
+           It must have attributes 'gettext' and 'ngettext',
+           serving as translation functions.
+        """
+        self.translator = translator
+        self._ = translator.gettext
+        self.ngettext = translator.ngettext
+
+
+def fixTimeOverflow(time):
+    """ Handle the overflow in the time portion (H, M, S) of "time":
+            (sign, y,m,d,H,M,S)
+
+        Overflow and underflow will at most affect the _days_ portion of
+        the date. We do not overflow days to months as we don't know _how_
+        to, generally.
+    """
+    # XXX we could conceivably use this function for handling regular dates
+    # XXX too - we just need to interrogate the month/year for the day
+    # XXX overflow...
+
+    sign, y, m, d, H, M, S = time
+    seconds = sign * (S + M*60 + H*60*60 + d*60*60*24)
+    if seconds:
+        sign = seconds<0 and -1 or 1
+        seconds *= sign
+        S = seconds%60
+        seconds /= 60
+        M = seconds%60
+        seconds /= 60
+        H = seconds%24
+        d = seconds / 24
+    else:
+        months = y*12 + m
+        sign = months<0 and -1 or 1
+        months *= sign
+        m = months%12
+        y = months/12
+
+    return (sign, y, m, d, H, M, S)
+
+class Range:
+    """Represents range between two values
+    Ranges can be created using one of theese two alternative syntaxes:
+
+    1. Native english syntax::
+
+            [[From] <value>][ To <value>]
+
+       Keywords "From" and "To" are case insensitive. Keyword "From" is
+       optional.
+
+    2. "Geek" syntax::
+
+          [<value>][; <value>]
+
+    Either first or second <value> can be omitted in both syntaxes.
+
+    Examples (consider local time is Sat Mar  8 22:07:48 EET 2003)::
+
+        >>> Range("from 2-12 to 4-2")
+        <Range from 2003-02-12.00:00:00 to 2003-04-02.00:00:00>
+
+        >>> Range("18:00 TO +2m")
+        <Range from 2003-03-08.18:00:00 to 2003-05-08.20:07:48>
+
+        >>> Range("12:00")
+        <Range from 2003-03-08.12:00:00 to None>
+
+        >>> Range("tO +3d")
+        <Range from None to 2003-03-11.20:07:48>
+
+        >>> Range("2002-11-10; 2002-12-12")
+        <Range from 2002-11-10.00:00:00 to 2002-12-12.00:00:00>
+
+        >>> Range("; 20:00 +1d")
+        <Range from None to 2003-03-09.20:00:00>
+
+    """
+    def __init__(self, spec, Type, allow_granularity=1, **params):
+        """Initializes Range of type <Type> from given <spec> string.
+
+        Sets two properties - from_value and to_value. None assigned to any of
+        this properties means "infinitum" (-infinitum to from_value and
+        +infinitum to to_value)
+
+        The Type parameter here should be class itself (e.g. Date), not a
+        class instance.
+
+        """
+        self.range_type = Type
+        re_range = r'(?:^|from(.+?))(?:to(.+?)$|$)'
+        re_geek_range = r'(?:^|(.+?));(?:(.+?)$|$)'
+        # Check which syntax to use
+        if  spec.find(';') == -1:
+            # Native english
+            mch_range = re.search(re_range, spec.strip(), re.IGNORECASE)
+        else:
+            # Geek
+            mch_range = re.search(re_geek_range, spec.strip())
+        if mch_range:
+            self.from_value, self.to_value = mch_range.groups()
+            if self.from_value:
+                self.from_value = Type(self.from_value.strip(), **params)
+            if self.to_value:
+                self.to_value = Type(self.to_value.strip(), **params)
+        else:
+            if allow_granularity:
+                self.from_value = Type(spec, **params)
+                self.to_value = Type(spec, add_granularity=1, **params)
+            else:
+                raise ValueError, "Invalid range"
+
+    def __str__(self):
+        return "from %s to %s" % (self.from_value, self.to_value)
+
+    def __repr__(self):
+        return "<Range %s>" % self.__str__()
+
+def test_range():
+    rspecs = ("from 2-12 to 4-2", "from 18:00 TO +2m", "12:00;", "tO +3d",
+        "2002-11-10; 2002-12-12", "; 20:00 +1d", '2002-10-12')
+    rispecs = ('from -1w 2d 4:32 to 4d', '-2w 1d')
+    for rspec in rspecs:
+        print '>>> Range("%s")' % rspec
+        print `Range(rspec, Date)`
+        print
+    for rspec in rispecs:
+        print '>>> Range("%s")' % rspec
+        print `Range(rspec, Interval)`
+        print
+
+def test():
+    intervals = ("  3w  1  d  2:00", " + 2d", "3w")
+    for interval in intervals:
+        print '>>> Interval("%s")'%interval
+        print `Interval(interval)`
+
+    dates = (".", "2000-06-25.19:34:02", ". + 2d", "1997-04-17", "01-25",
+        "08-13.22:13", "14:25", '2002-12')
+    for date in dates:
+        print '>>> Date("%s")'%date
+        print `Date(date)`
+
+    sums = ((". + 2d", "3w"), (".", "  3w  1  d  2:00"))
+    for date, interval in sums:
+        print '>>> Date("%s") + Interval("%s")'%(date, interval)
+        print `Date(date) + Interval(interval)`
+
+if __name__ == '__main__':
+    test()
+
+# vim: set filetype=python sts=4 sw=4 et si :

Added: tracker/vendor/roundup/current/roundup/exceptions.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/exceptions.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,19 @@
+#$Id: exceptions.py,v 1.1 2004/03/26 00:44:11 richard Exp $
+'''Exceptions for use across all Roundup components.
+'''
+
+__docformat__ = 'restructuredtext'
+
+class Reject(Exception):
+    '''An auditor may raise this exception when the current create or set
+    operation should be stopped.
+
+    It is up to the specific interface invoking the create or set to
+    handle this exception sanely. For example:
+
+    - mailgw will trap and ignore Reject for file attachments and messages
+    - cgi will trap and present the exception in a nice format
+    '''
+    pass
+
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/roundup/hyperdb.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/hyperdb.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,964 @@
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+#
+# $Id: hyperdb.py,v 1.119 2006/04/27 01:39:47 richard Exp $
+
+"""Hyperdatabase implementation, especially field types.
+"""
+__docformat__ = 'restructuredtext'
+
+# standard python modules
+import sys, os, time, re, shutil, weakref
+
+# roundup modules
+import date, password
+from support import ensureParentsExist, PrioList
+
+#
+# Types
+#
+class String:
+    """An object designating a String property."""
+    def __init__(self, indexme='no'):
+        self.indexme = indexme == 'yes'
+    def __repr__(self):
+        ' more useful for dumps '
+        return '<%s>'%self.__class__
+    def from_raw(self, value, **kw):
+        """fix the CRLF/CR -> LF stuff"""
+        return fixNewlines(value)
+
+class Password:
+    """An object designating a Password property."""
+    def __repr__(self):
+        ' more useful for dumps '
+        return '<%s>'%self.__class__
+    def from_raw(self, value, **kw):
+        m = password.Password.pwre.match(value)
+        if m:
+            # password is being given to us encrypted
+            p = password.Password()
+            p.scheme = m.group(1)
+            if p.scheme not in 'SHA crypt plaintext'.split():
+                raise HyperdbValueError, 'property %s: unknown encryption '\
+                    'scheme %r'%(kw['propname'], p.scheme)
+            p.password = m.group(2)
+            value = p
+        else:
+            try:
+                value = password.Password(value)
+            except password.PasswordValueError, message:
+                raise HyperdbValueError, 'property %s: %s'%(propname, message)
+        return value
+
+class Date:
+    """An object designating a Date property."""
+    def __init__(self, offset = None):
+        self._offset = offset
+    def __repr__(self):
+        ' more useful for dumps '
+        return '<%s>'%self.__class__
+    def offset (self, db) :
+        if self._offset is not None :
+            return self._offset
+        return db.getUserTimezone ()
+    def from_raw(self, value, db, **kw):
+        try:
+            value = date.Date(value).local(-self.offset(db))
+        except ValueError, message:
+            raise HyperdbValueError, 'property %s: %r is an invalid '\
+                'date (%s)'%(kw['propname'], value, message)
+        return value
+    def range_from_raw(self, value, db):
+        """return Range value from given raw value with offset correction"""
+        return date.Range(value, date.Date, offset=self.offset(db))
+
+class Interval:
+    """An object designating an Interval property."""
+    def __repr__(self):
+        ' more useful for dumps '
+        return '<%s>'%self.__class__
+    def from_raw(self, value, **kw):
+        try:
+            value = date.Interval(value)
+        except ValueError, message:
+            raise HyperdbValueError, 'property %s: %r is an invalid '\
+                'date interval (%s)'%(kw['propname'], value, message)
+        return value
+
+class Link:
+    """An object designating a Link property that links to a
+       node in a specified class."""
+    def __init__(self, classname, do_journal='yes'):
+        ''' Default is to not journal link and unlink events
+        '''
+        self.classname = classname
+        self.do_journal = do_journal == 'yes'
+    def __repr__(self):
+        ' more useful for dumps '
+        return '<%s to "%s">'%(self.__class__, self.classname)
+    def from_raw(self, value, db, propname, **kw):
+        if value == '-1' or not value:
+            value = None
+        else:
+            value = convertLinkValue(db, propname, self, value)
+        return value
+
+class Multilink:
+    """An object designating a Multilink property that links
+       to nodes in a specified class.
+
+       "classname" indicates the class to link to
+
+       "do_journal" indicates whether the linked-to nodes should have
+                    'link' and 'unlink' events placed in their journal
+    """
+    def __init__(self, classname, do_journal='yes'):
+        ''' Default is to not journal link and unlink events
+        '''
+        self.classname = classname
+        self.do_journal = do_journal == 'yes'
+    def __repr__(self):
+        ' more useful for dumps '
+        return '<%s to "%s">'%(self.__class__, self.classname)
+    def from_raw(self, value, db, klass, propname, itemid, **kw):
+        # get the current item value if it's not a new item
+        if itemid and not itemid.startswith('-'):
+            curvalue = klass.get(itemid, propname)
+        else:
+            curvalue = []
+
+        # if the value is a comma-separated string then split it now
+        if isinstance(value, type('')):
+            value = value.split(',')
+
+        # handle each add/remove in turn
+        # keep an extra list for all items that are
+        # definitely in the new list (in case of e.g.
+        # <propname>=A,+B, which should replace the old
+        # list with A,B)
+        set = 1
+        newvalue = []
+        for item in value:
+            item = item.strip()
+
+            # skip blanks
+            if not item: continue
+
+            # handle +/-
+            remove = 0
+            if item.startswith('-'):
+                remove = 1
+                item = item[1:]
+                set = 0
+            elif item.startswith('+'):
+                item = item[1:]
+                set = 0
+
+            # look up the value
+            itemid = convertLinkValue(db, propname, self, item)
+
+            # perform the add/remove
+            if remove:
+                try:
+                    curvalue.remove(itemid)
+                except ValueError:
+                    raise HyperdbValueError, 'property %s: %r is not ' \
+                        'currently an element'%(propname, item)
+            else:
+                newvalue.append(itemid)
+                if itemid not in curvalue:
+                    curvalue.append(itemid)
+
+        # that's it, set the new Multilink property value,
+        # or overwrite it completely
+        if set:
+            value = newvalue
+        else:
+            value = curvalue
+
+        # TODO: one day, we'll switch to numeric ids and this will be
+        # unnecessary :(
+        value = [int(x) for x in value]
+        value.sort()
+        value = [str(x) for x in value]
+        return value
+
+class Boolean:
+    """An object designating a boolean property"""
+    def __repr__(self):
+        'more useful for dumps'
+        return '<%s>' % self.__class__
+    def from_raw(self, value, **kw):
+        value = value.strip()
+        # checked is a common HTML checkbox value
+        value = value.lower() in ('checked', 'yes', 'true', 'on', '1')
+        return value
+
+class Number:
+    """An object designating a numeric property"""
+    def __repr__(self):
+        'more useful for dumps'
+        return '<%s>' % self.__class__
+    def from_raw(self, value, **kw):
+        value = value.strip()
+        try:
+            value = float(value)
+        except ValueError:
+            raise HyperdbValueError, 'property %s: %r is not a number'%(
+                kw['propname'], value)
+        return value
+#
+# Support for splitting designators
+#
+class DesignatorError(ValueError):
+    pass
+def splitDesignator(designator, dre=re.compile(r'([^\d]+)(\d+)')):
+    ''' Take a foo123 and return ('foo', 123)
+    '''
+    m = dre.match(designator)
+    if m is None:
+        raise DesignatorError, '"%s" not a node designator'%designator
+    return m.group(1), m.group(2)
+
+#
+# the base Database class
+#
+class DatabaseError(ValueError):
+    '''Error to be raised when there is some problem in the database code
+    '''
+    pass
+class Database:
+    '''A database for storing records containing flexible data types.
+
+This class defines a hyperdatabase storage layer, which the Classes use to
+store their data.
+
+
+Transactions
+------------
+The Database should support transactions through the commit() and
+rollback() methods. All other Database methods should be transaction-aware,
+using data from the current transaction before looking up the database.
+
+An implementation must provide an override for the get() method so that the
+in-database value is returned in preference to the in-transaction value.
+This is necessary to determine if any values have changed during a
+transaction.
+
+
+Implementation
+--------------
+
+All methods except __repr__ must be implemented by a concrete backend Database.
+
+'''
+
+    # flag to set on retired entries
+    RETIRED_FLAG = '__hyperdb_retired'
+
+    BACKEND_MISSING_STRING = None
+    BACKEND_MISSING_NUMBER = None
+    BACKEND_MISSING_BOOLEAN = None
+
+    def __init__(self, config, journaltag=None):
+        """Open a hyperdatabase given a specifier to some storage.
+
+        The 'storagelocator' is obtained from config.DATABASE.
+        The meaning of 'storagelocator' depends on the particular
+        implementation of the hyperdatabase.  It could be a file name,
+        a directory path, a socket descriptor for a connection to a
+        database over the network, etc.
+
+        The 'journaltag' is a token that will be attached to the journal
+        entries for any edits done on the database.  If 'journaltag' is
+        None, the database is opened in read-only mode: the Class.create(),
+        Class.set(), and Class.retire() methods are disabled.
+        """
+        raise NotImplementedError
+
+    def post_init(self):
+        """Called once the schema initialisation has finished.
+           If 'refresh' is true, we want to rebuild the backend
+           structures.
+        """
+        raise NotImplementedError
+
+    def refresh_database(self):
+        """Called to indicate that the backend should rebuild all tables
+           and structures. Not called in normal usage."""
+        raise NotImplementedError
+
+    def __getattr__(self, classname):
+        """A convenient way of calling self.getclass(classname)."""
+        raise NotImplementedError
+
+    def addclass(self, cl):
+        '''Add a Class to the hyperdatabase.
+        '''
+        raise NotImplementedError
+
+    def getclasses(self):
+        """Return a list of the names of all existing classes."""
+        raise NotImplementedError
+
+    def getclass(self, classname):
+        """Get the Class object representing a particular class.
+
+        If 'classname' is not a valid class name, a KeyError is raised.
+        """
+        raise NotImplementedError
+
+    def clear(self):
+        '''Delete all database contents.
+        '''
+        raise NotImplementedError
+
+    def getclassdb(self, classname, mode='r'):
+        '''Obtain a connection to the class db that will be used for
+           multiple actions.
+        '''
+        raise NotImplementedError
+
+    def addnode(self, classname, nodeid, node):
+        """Add the specified node to its class's db.
+        """
+        raise NotImplementedError
+
+    def serialise(self, classname, node):
+        '''Copy the node contents, converting non-marshallable data into
+           marshallable data.
+        '''
+        return node
+
+    def setnode(self, classname, nodeid, node):
+        '''Change the specified node.
+        '''
+        raise NotImplementedError
+
+    def unserialise(self, classname, node):
+        '''Decode the marshalled node data
+        '''
+        return node
+
+    def getnode(self, classname, nodeid):
+        '''Get a node from the database.
+
+        'cache' exists for backwards compatibility, and is not used.
+        '''
+        raise NotImplementedError
+
+    def hasnode(self, classname, nodeid):
+        '''Determine if the database has a given node.
+        '''
+        raise NotImplementedError
+
+    def countnodes(self, classname):
+        '''Count the number of nodes that exist for a particular Class.
+        '''
+        raise NotImplementedError
+
+    def storefile(self, classname, nodeid, property, content):
+        '''Store the content of the file in the database.
+
+           The property may be None, in which case the filename does not
+           indicate which property is being saved.
+        '''
+        raise NotImplementedError
+
+    def getfile(self, classname, nodeid, property):
+        '''Store the content of the file in the database.
+        '''
+        raise NotImplementedError
+
+    def addjournal(self, classname, nodeid, action, params):
+        ''' Journal the Action
+        'action' may be:
+
+            'create' or 'set' -- 'params' is a dictionary of property values
+            'link' or 'unlink' -- 'params' is (classname, nodeid, propname)
+            'retire' -- 'params' is None
+        '''
+        raise NotImplementedError
+
+    def getjournal(self, classname, nodeid):
+        ''' get the journal for id
+        '''
+        raise NotImplementedError
+
+    def pack(self, pack_before):
+        ''' pack the database
+        '''
+        raise NotImplementedError
+
+    def commit(self):
+        ''' Commit the current transactions.
+
+        Save all data changed since the database was opened or since the
+        last commit() or rollback().
+        '''
+        raise NotImplementedError
+
+    def rollback(self):
+        ''' Reverse all actions from the current transaction.
+
+        Undo all the changes made since the database was opened or the last
+        commit() or rollback() was performed.
+        '''
+        raise NotImplementedError
+
+    def close(self):
+        """Close the database.
+
+        This method must be called at the end of processing.
+
+        """
+
+#
+# The base Class class
+#
+class Class:
+    """ The handle to a particular class of nodes in a hyperdatabase.
+
+        All methods except __repr__ and getnode must be implemented by a
+        concrete backend Class.
+    """
+
+    def __init__(self, db, classname, **properties):
+        """Create a new class with a given name and property specification.
+
+        'classname' must not collide with the name of an existing class,
+        or a ValueError is raised.  The keyword arguments in 'properties'
+        must map names to property objects, or a TypeError is raised.
+        """
+        for name in 'creation activity creator actor'.split():
+            if properties.has_key(name):
+                raise ValueError, '"creation", "activity", "creator" and '\
+                    '"actor" are reserved'
+
+        self.classname = classname
+        self.properties = properties
+        self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
+        self.key = ''
+
+        # should we journal changes (default yes)
+        self.do_journal = 1
+
+        # do the db-related init stuff
+        db.addclass(self)
+
+        actions = "create set retire restore".split ()
+        self.auditors = dict ([(a, PrioList ()) for a in actions])
+        self.reactors = dict ([(a, PrioList ()) for a in actions])
+
+    def __repr__(self):
+        '''Slightly more useful representation
+        '''
+        return '<hyperdb.Class "%s">'%self.classname
+
+    # Editing nodes:
+
+    def create(self, **propvalues):
+        """Create a new node of this class and return its id.
+
+        The keyword arguments in 'propvalues' map property names to values.
+
+        The values of arguments must be acceptable for the types of their
+        corresponding properties or a TypeError is raised.
+
+        If this class has a key property, it must be present and its value
+        must not collide with other key strings or a ValueError is raised.
+
+        Any other properties on this class that are missing from the
+        'propvalues' dictionary are set to None.
+
+        If an id in a link or multilink property does not refer to a valid
+        node, an IndexError is raised.
+        """
+        raise NotImplementedError
+
+    _marker = []
+    def get(self, nodeid, propname, default=_marker, cache=1):
+        """Get the value of a property on an existing node of this class.
+
+        'nodeid' must be the id of an existing node of this class or an
+        IndexError is raised.  'propname' must be the name of a property
+        of this class or a KeyError is raised.
+
+        'cache' exists for backwards compatibility, and is not used.
+        """
+        raise NotImplementedError
+
+    # not in spec
+    def getnode(self, nodeid):
+        ''' Return a convenience wrapper for the node.
+
+        'nodeid' must be the id of an existing node of this class or an
+        IndexError is raised.
+
+        'cache' exists for backwards compatibility, and is not used.
+        '''
+        return Node(self, nodeid)
+
+    def getnodeids(self, retired=None):
+        '''Retrieve all the ids of the nodes for a particular Class.
+        '''
+        raise NotImplementedError
+
+    def set(self, nodeid, **propvalues):
+        """Modify a property on an existing node of this class.
+
+        'nodeid' must be the id of an existing node of this class or an
+        IndexError is raised.
+
+        Each key in 'propvalues' must be the name of a property of this
+        class or a KeyError is raised.
+
+        All values in 'propvalues' must be acceptable types for their
+        corresponding properties or a TypeError is raised.
+
+        If the value of the key property is set, it must not collide with
+        other key strings or a ValueError is raised.
+
+        If the value of a Link or Multilink property contains an invalid
+        node id, a ValueError is raised.
+        """
+        raise NotImplementedError
+
+    def retire(self, nodeid):
+        """Retire a node.
+
+        The properties on the node remain available from the get() method,
+        and the node's id is never reused.
+
+        Retired nodes are not returned by the find(), list(), or lookup()
+        methods, and other nodes may reuse the values of their key properties.
+        """
+        raise NotImplementedError
+
+    def restore(self, nodeid):
+        '''Restpre a retired node.
+
+        Make node available for all operations like it was before retirement.
+        '''
+        raise NotImplementedError
+
+    def is_retired(self, nodeid):
+        '''Return true if the node is rerired
+        '''
+        raise NotImplementedError
+
+    def destroy(self, nodeid):
+        """Destroy a node.
+
+        WARNING: this method should never be used except in extremely rare
+                 situations where there could never be links to the node being
+                 deleted
+
+        WARNING: use retire() instead
+
+        WARNING: the properties of this node will not be available ever again
+
+        WARNING: really, use retire() instead
+
+        Well, I think that's enough warnings. This method exists mostly to
+        support the session storage of the cgi interface.
+
+        The node is completely removed from the hyperdb, including all journal
+        entries. It will no longer be available, and will generally break code
+        if there are any references to the node.
+        """
+
+    def history(self, nodeid):
+        """Retrieve the journal of edits on a particular node.
+
+        'nodeid' must be the id of an existing node of this class or an
+        IndexError is raised.
+
+        The returned list contains tuples of the form
+
+            (date, tag, action, params)
+
+        'date' is a Timestamp object specifying the time of the change and
+        'tag' is the journaltag specified when the database was opened.
+        """
+        raise NotImplementedError
+
+    # Locating nodes:
+    def hasnode(self, nodeid):
+        '''Determine if the given nodeid actually exists
+        '''
+        raise NotImplementedError
+
+    def setkey(self, propname):
+        """Select a String property of this class to be the key property.
+
+        'propname' must be the name of a String property of this class or
+        None, or a TypeError is raised.  The values of the key property on
+        all existing nodes must be unique or a ValueError is raised.
+        """
+        raise NotImplementedError
+
+    def setlabelprop (self, labelprop):
+        """Set the label property. Used for override of labelprop
+           resolution order.
+        """
+        if labelprop not in self.getprops () :
+            raise ValueError, "Not a property name: %s" % labelprop
+        self._labelprop = labelprop
+
+    def setorderprop (self, orderprop):
+        """Set the order property. Used for override of orderprop
+           resolution order
+        """
+        if orderprop not in self.getprops () :
+            raise ValueError, "Not a property name: %s" % orderprop
+        self._orderprop = orderprop
+
+    def getkey(self):
+        """Return the name of the key property for this class or None."""
+        raise NotImplementedError
+
+    def labelprop(self, default_to_id=0):
+        """Return the property name for a label for the given node.
+
+        This method attempts to generate a consistent label for the node.
+        It tries the following in order:
+
+        0. self._labelprop if set
+        1. key property
+        2. "name" property
+        3. "title" property
+        4. first property from the sorted property name list
+        """
+        if hasattr (self, '_labelprop') :
+            return self._labelprop
+        k = self.getkey()
+        if  k:
+            return k
+        props = self.getprops()
+        if props.has_key('name'):
+            return 'name'
+        elif props.has_key('title'):
+            return 'title'
+        if default_to_id:
+            return 'id'
+        props = props.keys()
+        props.sort()
+        return props[0]
+
+    def orderprop (self):
+        """Return the property name to use for sorting for the given node.
+
+        This method computes the property for sorting.
+        It tries the following in order:
+
+        0. self._orderprop if set
+        1. "order" property
+        2. self.labelprop ()
+        """
+
+        if hasattr (self, '_orderprop') :
+            return self._orderprop
+        props = self.getprops ()
+        if props.has_key ('order'):
+            return 'order'
+        return self.labelprop ()
+
+    def lookup(self, keyvalue):
+        """Locate a particular node by its key property and return its id.
+
+        If this class has no key property, a TypeError is raised.  If the
+        'keyvalue' matches one of the values for the key property among
+        the nodes in this class, the matching node's id is returned;
+        otherwise a KeyError is raised.
+        """
+        raise NotImplementedError
+
+    def find(self, **propspec):
+        """Get the ids of nodes in this class which link to the given nodes.
+
+        'propspec' consists of keyword args propname={nodeid:1,}
+        'propname' must be the name of a property in this class, or a
+        KeyError is raised.  That property must be a Link or Multilink
+        property, or a TypeError is raised.
+
+        Any node in this class whose 'propname' property links to any of the
+        nodeids will be returned. Used by the full text indexing, which knows
+        that "foo" occurs in msg1, msg3 and file7, so we have hits on these
+        issues:
+
+            db.issue.find(messages={'1':1,'3':1}, files={'7':1})
+        """
+        raise NotImplementedError
+
+    def filter(self, search_matches, filterspec, sort=(None,None),
+            group=(None,None)):
+        """Return a list of the ids of the active nodes in this class that
+        match the 'filter' spec, sorted by the group spec and then the
+        sort spec.
+
+        "filterspec" is {propname: value(s)}
+
+        "sort" and "group" are (dir, prop) where dir is '+', '-' or None
+        and prop is a prop name or None
+
+        "search_matches" is {nodeid: marker}
+
+        The filter must match all properties specificed. If the property
+        value to match is a list:
+
+        1. String properties must match all elements in the list, and
+        2. Other properties must match any of the elements in the list.
+        """
+        raise NotImplementedError
+
+    def count(self):
+        """Get the number of nodes in this class.
+
+        If the returned integer is 'numnodes', the ids of all the nodes
+        in this class run from 1 to numnodes, and numnodes+1 will be the
+        id of the next node to be created in this class.
+        """
+        raise NotImplementedError
+
+    # Manipulating properties:
+    def getprops(self, protected=1):
+        """Return a dictionary mapping property names to property objects.
+           If the "protected" flag is true, we include protected properties -
+           those which may not be modified.
+        """
+        raise NotImplementedError
+
+    def addprop(self, **properties):
+        """Add properties to this class.
+
+        The keyword arguments in 'properties' must map names to property
+        objects, or a TypeError is raised.  None of the keys in 'properties'
+        may collide with the names of existing properties, or a ValueError
+        is raised before any properties have been added.
+        """
+        raise NotImplementedError
+
+    def index(self, nodeid):
+        """Add (or refresh) the node to search indexes"""
+        raise NotImplementedError
+
+    #
+    # Detector interface
+    #
+    def audit(self, event, detector, priority = 100):
+        """Register an auditor detector"""
+        self.auditors[event].append((priority, detector))
+
+    def fireAuditors(self, event, nodeid, newvalues):
+        """Fire all registered auditors"""
+        for prio, audit in self.auditors[event]:
+            audit(self.db, self, nodeid, newvalues)
+
+    def react(self, event, detector, priority = 100):
+        """Register a reactor detector"""
+        self.reactors[event].append((priority, detector))
+
+    def fireReactors(self, event, nodeid, oldvalues):
+        """Fire all registered reactors"""
+        for prio, react in self.reactors[event]:
+            react(self.db, self, nodeid, oldvalues)
+
+    #
+    # import / export support
+    #
+    def export_propnames(self):
+        """List the property names for export from this Class"""
+        propnames = self.getprops().keys()
+        propnames.sort()
+        return propnames
+
+
+class HyperdbValueError(ValueError):
+    ''' Error converting a raw value into a Hyperdb value '''
+    pass
+
+def convertLinkValue(db, propname, prop, value, idre=re.compile('^\d+$')):
+    ''' Convert the link value (may be id or key value) to an id value. '''
+    linkcl = db.classes[prop.classname]
+    if not idre.match(value):
+        if linkcl.getkey():
+            try:
+                value = linkcl.lookup(value)
+            except KeyError, message:
+                raise HyperdbValueError, 'property %s: %r is not a %s.'%(
+                    propname, value, prop.classname)
+        else:
+            raise HyperdbValueError, 'you may only enter ID values '\
+                'for property %s'%propname
+    return value
+
+def fixNewlines(text):
+    """ Homogenise line endings.
+
+        Different web clients send different line ending values, but
+        other systems (eg. email) don't necessarily handle those line
+        endings. Our solution is to convert all line endings to LF.
+    """
+    text = text.replace('\r\n', '\n')
+    return text.replace('\r', '\n')
+
+def rawToHyperdb(db, klass, itemid, propname, value, **kw):
+    ''' Convert the raw (user-input) value to a hyperdb-storable value. The
+        value is for the "propname" property on itemid (may be None for a
+        new item) of "klass" in "db".
+
+        The value is usually a string, but in the case of multilink inputs
+        it may be either a list of strings or a string with comma-separated
+        values.
+    '''
+    properties = klass.getprops()
+
+    # ensure it's a valid property name
+    propname = propname.strip()
+    try:
+        proptype =  properties[propname]
+    except KeyError:
+        raise HyperdbValueError, '%r is not a property of %s'%(propname,
+            klass.classname)
+
+    # if we got a string, strip it now
+    if isinstance(value, type('')):
+        value = value.strip()
+    # convert the input value to a real property value
+    value = proptype.from_raw \
+        ( value
+        , db = db
+        , klass = klass
+        , propname = propname
+        , itemid = itemid
+        , **kw
+        )
+    return value
+
+class FileClass:
+    ''' A class that requires the "content" property and stores it on
+        disk.
+    '''
+    default_mime_type = 'text/plain'
+
+    def __init__(self, db, classname, **properties):
+        '''The newly-created class automatically includes the "content"
+        property.
+        '''
+        if not properties.has_key('content'):
+            properties['content'] = hyperdb.String(indexme='yes')
+
+    def export_propnames(self):
+        ''' Don't export the "content" property
+        '''
+        propnames = self.getprops().keys()
+        propnames.remove('content')
+        propnames.sort()
+        return propnames
+
+    def exportFilename(self, dirname, nodeid):
+        subdir_filename = self.db.subdirFilename(self.classname, nodeid)
+        return os.path.join(dirname, self.classname+'-files', subdir_filename)
+
+    def export_files(self, dirname, nodeid):
+        ''' Export the "content" property as a file, not csv column
+        '''
+        source = self.db.filename(self.classname, nodeid)
+
+        dest = self.exportFilename(dirname, nodeid)
+        ensureParentsExist(dest)
+        shutil.copyfile(source, dest)
+
+    def import_files(self, dirname, nodeid):
+        ''' Import the "content" property as a file
+        '''
+        source = self.exportFilename(dirname, nodeid)
+
+        dest = self.db.filename(self.classname, nodeid, create=1)
+        ensureParentsExist(dest)
+        shutil.copyfile(source, dest)
+
+        mime_type = None
+        if self.getprops().has_key('type'):
+            mime_type = self.get(nodeid, 'type')
+        if not mime_type:
+            mime_type = self.default_mime_type
+        self.db.indexer.add_text((self.classname, nodeid, 'content'),
+            self.get(nodeid, 'content'), mime_type)
+
+class Node:
+    ''' A convenience wrapper for the given node
+    '''
+    def __init__(self, cl, nodeid, cache=1):
+        self.__dict__['cl'] = cl
+        self.__dict__['nodeid'] = nodeid
+    def keys(self, protected=1):
+        return self.cl.getprops(protected=protected).keys()
+    def values(self, protected=1):
+        l = []
+        for name in self.cl.getprops(protected=protected).keys():
+            l.append(self.cl.get(self.nodeid, name))
+        return l
+    def items(self, protected=1):
+        l = []
+        for name in self.cl.getprops(protected=protected).keys():
+            l.append((name, self.cl.get(self.nodeid, name)))
+        return l
+    def has_key(self, name):
+        return self.cl.getprops().has_key(name)
+    def get(self, name, default=None):
+        if self.has_key(name):
+            return self[name]
+        else:
+            return default
+    def __getattr__(self, name):
+        if self.__dict__.has_key(name):
+            return self.__dict__[name]
+        try:
+            return self.cl.get(self.nodeid, name)
+        except KeyError, value:
+            # we trap this but re-raise it as AttributeError - all other
+            # exceptions should pass through untrapped
+            pass
+        # nope, no such attribute
+        raise AttributeError, str(value)
+    def __getitem__(self, name):
+        return self.cl.get(self.nodeid, name)
+    def __setattr__(self, name, value):
+        try:
+            return self.cl.set(self.nodeid, **{name: value})
+        except KeyError, value:
+            raise AttributeError, str(value)
+    def __setitem__(self, name, value):
+        self.cl.set(self.nodeid, **{name: value})
+    def history(self):
+        return self.cl.history(self.nodeid)
+    def retire(self):
+        return self.cl.retire(self.nodeid)
+
+
+def Choice(name, db, *options):
+    '''Quick helper to create a simple class with choices
+    '''
+    cl = Class(db, name, name=String(), order=String())
+    for i in range(len(options)):
+        cl.create(name=options[i], order=i)
+    return hyperdb.Link(name)
+
+# vim: set filetype=python sts=4 sw=4 et si :

Added: tracker/vendor/roundup/current/roundup/i18n.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/i18n.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,229 @@
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+#
+# $Id: i18n.py,v 1.15 2005/06/14 05:33:32 a1s Exp $
+
+"""
+RoundUp Internationalization (I18N)
+
+To use this module, the following code should be used::
+
+    from roundup.i18n import _
+    ...
+    print _("Some text that can be translated")
+
+Note that to enable re-ordering of inserted texts in formatting strings
+(which can easily happen if a sentence has to be re-ordered due to
+grammatical changes), translatable formats should use named format specs::
+
+    ... _('Index of %(classname)s') % {'classname': cn} ...
+
+Also, this eases the job of translators since they have some context what
+the dynamic portion of a message really means.
+"""
+__docformat__ = 'restructuredtext'
+
+import errno
+import gettext as gettext_module
+import os
+
+from roundup import msgfmt
+
+# List of directories for mo file search (see SF bug 1219689)
+LOCALE_DIRS = [
+    gettext_module._default_localedir,
+]
+# compute mo location relative to roundup installation directory
+# (prefix/lib/python/site-packages/roundup/msgfmt.py on posix systems,
+# prefix/lib/site-packages/roundup/msgfmt.py on windows).
+# locale root is prefix/share/locale.
+if os.name == "nt":
+    _mo_path = [".."] * 4 + ["share", "locale"]
+else:
+    _mo_path = [".."] * 5 + ["share", "locale"]
+_mo_path = os.path.normpath(os.path.join(msgfmt.__file__, *_mo_path))
+if _mo_path not in LOCALE_DIRS:
+    LOCALE_DIRS.append(_mo_path)
+del _mo_path
+
+# Roundup text domain
+DOMAIN = "roundup"
+
+if hasattr(gettext_module.GNUTranslations, "ngettext"):
+    # gettext_module has everything needed
+    RoundupNullTranslations = gettext_module.NullTranslations
+    RoundupTranslations = gettext_module.GNUTranslations
+else:
+    # prior to 2.3, there was no plural forms.  mix simple emulation in
+    class PluralFormsMixIn:
+        def ngettext(self, singular, plural, count):
+            if count == 1:
+                _msg = singular
+            else:
+                _msg = plural
+            return self.gettext(_msg)
+        def ungettext(self, singular, plural, count):
+            if count == 1:
+                _msg = singular
+            else:
+                _msg = plural
+            return self.ugettext(_msg)
+    class RoundupNullTranslations(
+        gettext_module.NullTranslations, PluralFormsMixIn
+    ):
+        pass
+    class RoundupTranslations(
+        gettext_module.GNUTranslations, PluralFormsMixIn
+    ):
+        pass
+
+def find_locales(language=None):
+    """Return normalized list of locale names to try for given language
+
+    Argument 'language' may be a single language code or a list of codes.
+    If 'language' is omitted or None, use locale settings in OS environment.
+
+    """
+    # body of this function is borrowed from gettext_module.find()
+    if language is None:
+        languages = []
+        for envar in ('LANGUAGE', 'LC_ALL', 'LC_MESSAGES', 'LANG'):
+            val = os.environ.get(envar)
+            if val:
+                languages = val.split(':')
+                break
+    elif isinstance(language, str) or  isinstance(language, unicode):
+        languages = [language]
+    else:
+        # 'language' must be iterable
+        languages = language
+    # now normalize and expand the languages
+    nelangs = []
+    for lang in languages:
+        for nelang in gettext_module._expand_lang(lang):
+            if nelang not in nelangs:
+                nelangs.append(nelang)
+    return nelangs
+
+def get_mofile(languages, localedir, domain=None):
+    """Return the first of .mo files found in localedir for languages
+
+    Parameters:
+        languages:
+            list of locale names to try
+        localedir:
+            path to directory containing locale files.
+            Usually this is either gettext_module._default_localedir
+            or 'locale' subdirectory in the tracker home.
+        domain:
+            optional name of messages domain.
+            If omitted or None, work with simplified
+            locale directory, as used in tracker homes:
+            message catalogs are kept in files locale.po
+            instead of locale/LC_MESSAGES/domain.po
+
+    Return the path of the first .mo file found.
+    If nothing found, return None.
+
+    Automatically compile .po files if necessary.
+
+    """
+    for locale in languages:
+        if locale == "C":
+            break
+        if domain:
+            basename = os.path.join(localedir, locale, "LC_MESSAGES", domain)
+        else:
+            basename = os.path.join(localedir, locale)
+        # look for message catalog files, check timestamps
+        mofile = basename + ".mo"
+        if os.path.isfile(mofile):
+            motime = os.path.getmtime(mofile)
+        else:
+            motime = 0
+        pofile = basename + ".po"
+        if os.path.isfile(pofile):
+            potime = os.path.getmtime(pofile)
+        else:
+            potime = 0
+        # see what we've found
+        if motime < potime:
+            # compile
+            msgfmt.make(pofile, mofile)
+        elif motime == 0:
+            # no files found - proceed to the next locale name
+            continue
+        # .mo file found or made
+        return mofile
+    return None
+
+def get_translation(language=None, tracker_home=None,
+    translation_class=RoundupTranslations,
+    null_translation_class=RoundupNullTranslations
+):
+    """Return Translation object for given language and domain
+
+    Argument 'language' may be a single language code or a list of codes.
+    If 'language' is omitted or None, use locale settings in OS environment.
+
+    Arguments 'translation_class' and 'null_translation_class'
+    specify the classes that are instantiated for existing
+    and non-existing translations, respectively.
+
+    """
+    mofiles = []
+    # locale directory paths
+    if tracker_home is None:
+        tracker_locale = None
+    else:
+        tracker_locale = os.path.join(tracker_home, "locale")
+    # get the list of locales
+    locales = find_locales(language)
+    # add mofiles found in the tracker, then in the system locale directory
+    if tracker_locale:
+        mofiles.append(get_mofile(locales, tracker_locale))
+    for system_locale in LOCALE_DIRS:
+        mofiles.append(get_mofile(locales, system_locale, DOMAIN))
+    # we want to fall back to english unless english is selected language
+    if "en" not in locales:
+        locales = find_locales("en")
+        # add mofiles found in the tracker, then in the system locale directory
+        if tracker_locale:
+            mofiles.append(get_mofile(locales, tracker_locale))
+        for system_locale in LOCALE_DIRS:
+            mofiles.append(get_mofile(locales, system_locale, DOMAIN))
+    # filter out elements that are not found
+    mofiles = filter(None, mofiles)
+    if mofiles:
+        translator = translation_class(open(mofiles[0], "rb"))
+        for mofile in mofiles[1:]:
+            # note: current implementation of gettext_module
+            #   always adds fallback to the end of the fallback chain.
+            translator.add_fallback(translation_class(open(mofile, "rb")))
+    else:
+        translator = null_translation_class()
+    return translator
+
+# static translations object
+translation = get_translation()
+# static translation functions
+_ = gettext = translation.gettext
+ugettext = translation.ugettext
+ngettext = translation.ngettext
+ungettext = translation.ungettext
+
+# vim: set filetype=python sts=4 sw=4 et si :

Added: tracker/vendor/roundup/current/roundup/init.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/init.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,189 @@
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+#
+# $Id: init.py,v 1.36 2005/12/03 11:22:50 a1s Exp $
+
+"""Init (create) a roundup instance.
+"""
+__docformat__ = 'restructuredtext'
+
+import os, errno, rfc822
+
+from roundup import install_util, password
+from roundup.configuration import CoreConfig
+from roundup.i18n import _
+
+def copytree(src, dst, symlinks=0):
+    """Recursively copy a directory tree using copyDigestedFile().
+
+    The destination directory os allowed to exist.
+
+    If the optional symlinks flag is true, symbolic links in the
+    source tree result in symbolic links in the destination tree; if
+    it is false, the contents of the files pointed to by symbolic
+    links are copied.
+
+    This was copied from shutil.py in std lib.
+    """
+    names = os.listdir(src)
+    try:
+        os.mkdir(dst)
+    except OSError, error:
+        if error.errno != errno.EEXIST: raise
+    for name in names:
+        srcname = os.path.join(src, name)
+        dstname = os.path.join(dst, name)
+        if symlinks and os.path.islink(srcname):
+            linkto = os.readlink(srcname)
+            os.symlink(linkto, dstname)
+        elif os.path.isdir(srcname):
+            copytree(srcname, dstname, symlinks)
+        else:
+            install_util.copyDigestedFile(srcname, dstname)
+
+def install(instance_home, template, settings={}):
+    '''Install an instance using the named template and backend.
+
+    'instance_home'
+       the directory to place the instance data in
+    'template'
+       the directory holding the template to use in creating the instance data
+    'settings'
+       config.ini setting overrides (dictionary)
+
+    The instance_home directory will be created using the files found in
+    the named template (roundup.templates.<name>). A usual instance_home
+    contains:
+
+    config.ini
+      tracker configuration file
+    schema.py
+      database schema definition
+    initial_data.py
+      database initialization script, used to populate the database
+      with 'roundup-admin init' command
+    interfaces.py
+      (optional, not installed from standard templates) defines
+      the CGI Client and mail gateway MailGW classes that are
+      used by roundup.cgi, roundup-server and roundup-mailgw.
+    db/
+      the actual database that stores the instance's data
+    html/
+      the html templates that are used by the CGI Client
+    detectors/
+      the auditor and reactor modules for this instance
+    extensions/
+      code extensions to Roundup
+    '''
+    # At the moment, it's just a copy
+    copytree(template, instance_home)
+
+    # rename the tempate in the TEMPLATE-INFO.txt file
+    ti = loadTemplateInfo(instance_home)
+    ti['name'] = ti['name'] + '-' + os.path.split(instance_home)[1]
+    saveTemplateInfo(instance_home, ti)
+
+    # if there is no config.ini or old-style config.py
+    # installed from the template, write default config text
+    config_ini_file = os.path.join(instance_home, CoreConfig.INI_FILE)
+    if not os.path.isfile(config_ini_file):
+        config = CoreConfig(settings=settings)
+        config.save(config_ini_file)
+
+
+def listTemplates(dir):
+    ''' List all the Roundup template directories in a given directory.
+
+        Find all the dirs that contain a TEMPLATE-INFO.txt and parse it.
+
+        Return a list of dicts of info about the templates.
+    '''
+    ret = {}
+    for idir in os.listdir(dir):
+        idir = os.path.join(dir, idir)
+        ti = loadTemplateInfo(idir)
+        if ti:
+            ret[ti['name']] = ti
+    return ret
+
+def loadTemplateInfo(dir):
+    ''' Attempt to load a Roundup template from the indicated directory.
+
+        Return None if there's no template, otherwise a template info
+        dictionary.
+    '''
+    ti = os.path.join(dir, 'TEMPLATE-INFO.txt')
+    if not os.path.exists(ti):
+        return None
+
+    if os.path.exists(os.path.join(dir, 'config.py')):
+        print _("WARNING: directory '%s'\n"
+            "\tcontains old-style template - ignored"
+            ) % os.path.abspath(dir)
+        return None
+
+    # load up the template's information
+    f = open(ti)
+    try:
+        m = rfc822.Message(open(ti))
+        ti = {}
+        ti['name'] = m['name']
+        ti['description'] = m['description']
+        ti['intended-for'] = m['intended-for']
+        ti['path'] = dir
+    finally:
+        f.close()
+    return ti
+
+def writeHeader(name, value):
+    ''' Write an rfc822-compatible header line, making it wrap reasonably
+    '''
+    out = [name.capitalize() + ':']
+    n = len(out[0])
+    for word in value.split():
+        if len(word) + n > 74:
+            out.append('\n')
+            n = 0
+        out.append(' ' + word)
+        n += len(out[-1])
+    return ''.join(out) + '\n'
+
+def saveTemplateInfo(dir, info):
+    ''' Save the template info (dict of values) to the TEMPLATE-INFO.txt
+        file in the indicated directory.
+    '''
+    ti = os.path.join(dir, 'TEMPLATE-INFO.txt')
+    f = open(ti, 'w')
+    try:
+        for name in 'name description intended-for path'.split():
+            f.write(writeHeader(name, info[name]))
+    finally:
+        f.close()
+
+def write_select_db(instance_home, backend):
+    ''' Write the file that selects the backend for the tracker
+    '''
+    dbdir = os.path.join(instance_home, 'db')
+    if not os.path.exists(dbdir):
+        os.makedirs(dbdir)
+    f = open(os.path.join(dbdir, 'backend_name'), 'w')
+    f.write(backend+'\n')
+    f.close()
+
+
+
+# vim: set filetype=python sts=4 sw=4 et si :

Added: tracker/vendor/roundup/current/roundup/install_util.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/install_util.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,158 @@
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+# 
+# $Id: install_util.py,v 1.11 2006/01/25 03:11:43 richard Exp $
+
+"""Support module to generate and check fingerprints of installed files.
+"""
+__docformat__ = 'restructuredtext'
+
+import os, sha, shutil
+
+sgml_file_types = [".xml", ".ent", ".html"]
+hash_file_types = [".py", ".sh", ".conf", ".cgi"]
+slast_file_types = [".css"]
+
+digested_file_types = sgml_file_types + hash_file_types + slast_file_types
+
+def extractFingerprint(lines):
+    # get fingerprint from last line
+    if lines[-1].startswith("#SHA: "):
+        # handle .py/.sh comment
+        return lines[-1][6:].strip()
+    elif lines[-1].startswith("<!-- SHA: "):
+        # handle xml/html files
+        fingerprint = lines[-1][10:]
+        fingerprint = fingerprint.replace('-->', '')
+        return fingerprint.strip()
+    elif lines[-1].startswith("/* SHA: "):
+        # handle css files
+        fingerprint = lines[-1][8:]
+        fingerprint = fingerprint.replace('*/', '')
+        return fingerprint.strip()
+    return None
+
+def checkDigest(filename):
+    """Read file, check for valid fingerprint, return TRUE if ok"""
+    # open and read file
+    inp = open(filename, "r")
+    lines = inp.readlines()
+    inp.close()
+
+    fingerprint = extractFingerprint(lines)
+    if fingerprint is None:
+        return 0
+    del lines[-1]
+
+    # calculate current digest
+    digest = sha.new()
+    for line in lines:
+        digest.update(line)
+
+    # compare current to stored digest
+    return fingerprint == digest.hexdigest()
+
+
+class DigestFile:
+    """ A class that you can use like open() and that calculates
+        and writes a SHA digest to the target file.
+    """
+
+    def __init__(self, filename):
+        self.filename = filename
+        self.digest = sha.new()
+        self.file = open(self.filename, "w")
+
+    def write(self, data):
+        lines = data.splitlines()
+        # if the file is coming from an installed tracker being used as a
+        # template, then we will want to re-calculate the SHA
+        fingerprint = extractFingerprint(lines)
+        if fingerprint is not None:
+            data = '\n'.join(lines[:-1]) + '\n'
+        self.file.write(data)
+        self.digest.update(data)
+
+    def close(self):
+        file, ext = os.path.splitext(self.filename)
+
+        if ext in sgml_file_types:
+            self.file.write("<!-- SHA: %s -->\n" % (self.digest.hexdigest(),))
+        elif ext in hash_file_types:
+            self.file.write("#SHA: %s\n" % (self.digest.hexdigest(),))
+        elif ext in slast_file_types:
+            self.file.write("/* SHA: %s */\n" % (self.digest.hexdigest(),))
+
+        self.file.close()
+
+
+def copyDigestedFile(src, dst, copystat=1):
+    """ Copy data from `src` to `dst`, adding a fingerprint to `dst`.
+        If `copystat` is true, the file status is copied, too
+        (like shutil.copy2).
+    """
+    if os.path.isdir(dst):
+        dst = os.path.join(dst, os.path.basename(src))
+
+    dummy, ext = os.path.splitext(src)
+    if ext not in digested_file_types:
+        if copystat:
+            return shutil.copy2(src, dst)
+        else:
+            return shutil.copyfile(src, dst)
+
+    fsrc = None
+    fdst = None
+    try:
+        fsrc = open(src, 'r')
+        fdst = DigestFile(dst)
+        shutil.copyfileobj(fsrc, fdst)
+    finally:
+        if fdst: fdst.close()
+        if fsrc: fsrc.close()
+
+    if copystat: shutil.copystat(src, dst)
+
+
+def test():
+    import sys
+
+    testdata = open(sys.argv[0], 'r').read()
+
+    for ext in digested_file_types:
+        testfile = "__digest_test" + ext
+
+        out = DigestFile(testfile)
+        out.write(testdata)
+        out.close()
+
+        assert checkDigest(testfile), "digest ok w/o modification"
+
+        mod = open(testfile, 'r+')
+        mod.seek(0)
+        mod.write('# changed!')
+        mod.close()
+
+        assert not checkDigest(testfile), "digest fails after modification"
+
+        os.remove(testfile)
+
+
+if __name__ == '__main__':
+    test()
+
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/roundup/instance.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/instance.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,246 @@
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+#
+# $Id: instance.py,v 1.33 2004/11/29 02:55:46 richard Exp $
+
+'''Tracker handling (open tracker).
+
+Backwards compatibility for the old-style "imported" trackers.
+'''
+__docformat__ = 'restructuredtext'
+
+import os
+from roundup import configuration, mailgw
+from roundup import hyperdb, backends
+from roundup.cgi import client, templating
+
+class Vars:
+    def __init__(self, vars):
+        self.__dict__.update(vars)
+
+class Tracker:
+    def __init__(self, tracker_home, optimize=0):
+        """New-style tracker instance constructor
+
+        Parameters:
+            tracker_home:
+                tracker home directory
+            optimize:
+                if set, precompile html templates
+
+        """
+        self.tracker_home = tracker_home
+        self.optimize = optimize
+        self.config = configuration.CoreConfig(tracker_home)
+        self.cgi_actions = {}
+        self.templating_utils = {}
+        self.load_interfaces()
+        self.templates = templating.Templates(self.config["TEMPLATES"])
+        self.backend = backends.get_backend(self.get_backend_name())
+        if self.optimize:
+            self.templates.precompileTemplates()
+            # initialize tracker extensions
+            for extension in self.get_extensions('extensions'):
+                extension(self)
+            # load database schema
+            schemafilename = os.path.join(self.tracker_home, 'schema.py')
+            # Note: can't use built-in open()
+            #   because of the global function with the same name
+            schemafile = file(schemafilename, 'rt')
+            self.schema = compile(schemafile.read(), schemafilename, 'exec')
+            schemafile.close()
+            # load database detectors
+            self.detectors = self.get_extensions('detectors')
+            # db_open is set to True after first open()
+            self.db_open = 0
+
+    def get_backend_name(self):
+        o = __builtins__['open']
+        f = o(os.path.join(self.tracker_home, 'db', 'backend_name'))
+        name = f.readline().strip()
+        f.close()
+        return name
+
+    def open(self, name=None):
+        # load the database schema
+        # we cannot skip this part even if self.optimize is set
+        # because the schema has security settings that must be
+        # applied to each database instance
+        backend = self.backend
+        vars = {
+            'Class': backend.Class,
+            'FileClass': backend.FileClass,
+            'IssueClass': backend.IssueClass,
+            'String': hyperdb.String,
+            'Password': hyperdb.Password,
+            'Date': hyperdb.Date,
+            'Link': hyperdb.Link,
+            'Multilink': hyperdb.Multilink,
+            'Interval': hyperdb.Interval,
+            'Boolean': hyperdb.Boolean,
+            'Number': hyperdb.Number,
+            'db': backend.Database(self.config, name)
+        }
+
+        if self.optimize:
+            # execute preloaded schema object
+            exec(self.schema, vars)
+            # use preloaded detectors
+            detectors = self.detectors
+        else:
+            # execute the schema file
+            self._load_python('schema.py', vars)
+            # reload extensions and detectors
+            for extension in self.get_extensions('extensions'):
+                extension(self)
+            detectors = self.get_extensions('detectors')
+        db = vars['db']
+        # apply the detectors
+        for detector in detectors:
+            detector(db)
+        # if we are running in debug mode
+        # or this is the first time the database is opened,
+        # do database upgrade checks
+        if not (self.optimize and self.db_open):
+            db.post_init()
+            self.db_open = 1
+        return db
+
+    def load_interfaces(self):
+        """load interfaces.py (if any), initialize Client and MailGW attrs"""
+        vars = {}
+        if os.path.isfile(os.path.join(self.tracker_home, 'interfaces.py')):
+            self._load_python('interfaces.py', vars)
+        self.Client = vars.get('Client', client.Client)
+        self.MailGW = vars.get('MailGW', mailgw.MailGW)
+
+    def get_extensions(self, dirname):
+        """Load python extensions
+
+        Parameters:
+            dirname:
+                extension directory name relative to tracker home
+
+        Return value:
+            list of init() functions for each extension
+
+        """
+        extensions = []
+        dirpath = os.path.join(self.tracker_home, dirname)
+        if os.path.isdir(dirpath):
+            for name in os.listdir(dirpath):
+                if not name.endswith('.py'):
+                    continue
+                vars = {}
+                self._load_python(os.path.join(dirname, name), vars)
+                extensions.append(vars['init'])
+        return extensions
+
+    def init(self, adminpw):
+        db = self.open('admin')
+        self._load_python('initial_data.py', {'db': db, 'adminpw': adminpw,
+            'admin_email': self.config['ADMIN_EMAIL']})
+        db.commit()
+        db.close()
+
+    def exists(self):
+        return self.backend.db_exists(self.config)
+
+    def nuke(self):
+        self.backend.db_nuke(self.config)
+
+    def _load_python(self, file, vars):
+        file = os.path.join(self.tracker_home, file)
+        execfile(file, vars)
+        return vars
+
+    def registerAction(self, name, action):
+        self.cgi_actions[name] = action
+
+    def registerUtil(self, name, function):
+        self.templating_utils[name] = function
+
+class TrackerError(Exception):
+    pass
+
+
+class OldStyleTrackers:
+    def __init__(self):
+        self.number = 0
+        self.trackers = {}
+
+    def open(self, tracker_home, optimize=0):
+        """Open the tracker.
+
+        Parameters:
+            tracker_home:
+                tracker home directory
+            optimize:
+                if set, precompile html templates
+
+        Raise ValueError if the tracker home doesn't exist.
+
+        """
+        import imp
+        # sanity check existence of tracker home
+        if not os.path.exists(tracker_home):
+            raise ValueError, 'no such directory: "%s"'%tracker_home
+
+        # sanity check tracker home contents
+        for reqd in 'config dbinit select_db interfaces'.split():
+            if not os.path.exists(os.path.join(tracker_home, '%s.py'%reqd)):
+                raise TrackerError, 'File "%s.py" missing from tracker '\
+                    'home "%s"'%(reqd, tracker_home)
+
+        if self.trackers.has_key(tracker_home):
+            return imp.load_package(self.trackers[tracker_home],
+                tracker_home)
+        # register all available backend modules
+        backends.list_backends()
+        self.number = self.number + 1
+        modname = '_roundup_tracker_%s'%self.number
+        self.trackers[tracker_home] = modname
+
+        # load the tracker
+        tracker = imp.load_package(modname, tracker_home)
+
+        # ensure the tracker has all the required bits
+        for required in 'open init Client MailGW'.split():
+            if not hasattr(tracker, required):
+                raise TrackerError, \
+                    'Required tracker attribute "%s" missing'%required
+
+        # load and apply the config
+        tracker.config = configuration.CoreConfig(tracker_home)
+        tracker.dbinit.config = tracker.config
+
+        tracker.optimize = optimize
+        tracker.templates = templating.Templates(tracker.config["TEMPLATES"])
+        if optimize:
+            tracker.templates.precompileTemplates()
+
+        return tracker
+
+OldStyleTrackers = OldStyleTrackers()
+def open(tracker_home, optimize=0):
+    if os.path.exists(os.path.join(tracker_home, 'dbinit.py')):
+        # user should upgrade...
+        return OldStyleTrackers.open(tracker_home, optimize=optimize)
+
+    return Tracker(tracker_home, optimize=optimize)
+
+# vim: set filetype=python sts=4 sw=4 et si :

Added: tracker/vendor/roundup/current/roundup/mailer.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/mailer.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,215 @@
+"""Sending Roundup-specific mail over SMTP.
+"""
+__docformat__ = 'restructuredtext'
+# $Id: mailer.py,v 1.17 2006/02/21 05:48:23 a1s Exp $
+
+import time, quopri, os, socket, smtplib, re, sys, traceback
+
+from cStringIO import StringIO
+from MimeWriter import MimeWriter
+
+from roundup.rfc2822 import encode_header
+from roundup import __version__
+
+try:
+    from email.Utils import formatdate
+except ImportError:
+    def formatdate():
+        return time.strftime("%a, %d %b %Y %H:%M:%S +0000", time.gmtime())
+
+class MessageSendError(RuntimeError):
+    pass
+
+class Mailer:
+    """Roundup-specific mail sending."""
+    def __init__(self, config):
+        self.config = config
+
+        # set to indicate to roundup not to actually _send_ email
+        # this var must contain a file to write the mail to
+        self.debug = os.environ.get('SENDMAILDEBUG', '') \
+            or config["MAIL_DEBUG"]
+
+    def get_standard_message(self, to, subject, author=None):
+        '''Form a standard email message from Roundup.
+
+        "to"      - recipients list
+        "subject" - Subject
+        "author"  - (name, address) tuple or None for admin email
+
+        Subject and author are encoded using the EMAIL_CHARSET from the
+        config (default UTF-8).
+
+        Returns a Message object and body part writer.
+        '''
+        # encode header values if they need to be
+        charset = getattr(self.config, 'EMAIL_CHARSET', 'utf-8')
+        tracker_name = self.config.TRACKER_NAME
+        if charset != 'utf-8':
+            tracker = unicode(tracker_name, 'utf-8').encode(charset)
+        if not author:
+            author = straddr((tracker_name, self.config.ADMIN_EMAIL))
+        else:
+            name = author[0]
+            if charset != 'utf-8':
+                name = unicode(name, 'utf-8').encode(charset)
+            author = straddr((encode_header(name, charset), author[1]))
+
+        message = StringIO()
+        writer = MimeWriter(message)
+        writer.addheader('Subject', encode_header(subject, charset))
+        writer.addheader('To', ', '.join(to))
+        writer.addheader('From', author)
+        writer.addheader('Date', formatdate())
+
+        # Add a unique Roundup header to help filtering
+        writer.addheader('X-Roundup-Name', encode_header(tracker_name,
+            charset))
+        # and another one to avoid loops
+        writer.addheader('X-Roundup-Loop', 'hello')
+        # finally, an aid to debugging problems
+        writer.addheader('X-Roundup-Version', __version__)
+
+        writer.addheader('MIME-Version', '1.0')
+
+        return message, writer
+
+    def standard_message(self, to, subject, content, author=None):
+        """Send a standard message.
+
+        Arguments:
+        - to: a list of addresses usable by rfc822.parseaddr().
+        - subject: the subject as a string.
+        - content: the body of the message as a string.
+        - author: the sender as a (name, address) tuple
+        """
+        message, writer = self.get_standard_message(to, subject, author)
+
+        writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
+        body = writer.startbody('text/plain; charset=utf-8')
+        content = StringIO(content)
+        quopri.encode(content, body, 0)
+
+        self.smtp_send(to, message)
+
+    def bounce_message(self, bounced_message, to, error,
+                       subject='Failed issue tracker submission'):
+        """Bounce a message, attaching the failed submission.
+
+        Arguments:
+        - bounced_message: an RFC822 Message object.
+        - to: a list of addresses usable by rfc822.parseaddr(). Might be
+          extended or overridden according to the config
+          ERROR_MESSAGES_TO setting.
+        - error: the reason of failure as a string.
+        - subject: the subject as a string.
+
+        """
+        # see whether we should send to the dispatcher or not
+        dispatcher_email = getattr(self.config, "DISPATCHER_EMAIL",
+            getattr(self.config, "ADMIN_EMAIL"))
+        error_messages_to = getattr(self.config, "ERROR_MESSAGES_TO", "user")
+        if error_messages_to == "dispatcher":
+            to = [dispatcher_email]
+        elif error_messages_to == "both":
+            to.append(dispatcher_email)
+
+        message, writer = self.get_standard_message(to, subject)
+
+        part = writer.startmultipartbody('mixed')
+        part = writer.nextpart()
+        part.addheader('Content-Transfer-Encoding', 'quoted-printable')
+        body = part.startbody('text/plain; charset=utf-8')
+        body.write(quopri.encodestring ('\n'.join(error)))
+
+        # attach the original message to the returned message
+        part = writer.nextpart()
+        part.addheader('Content-Disposition', 'attachment')
+        part.addheader('Content-Description', 'Message you sent')
+        body = part.startbody('text/plain')
+
+        for header in bounced_message.headers:
+            body.write(header)
+        body.write('\n')
+        try:
+            bounced_message.rewindbody()
+        except IOError, message:
+            body.write("*** couldn't include message body: %s ***"
+                       % bounced_message)
+        else:
+            body.write(bounced_message.fp.read())
+
+        writer.lastpart()
+
+        self.smtp_send(to, message)
+
+    def exception_message(self):
+        '''Send a message to the admins with information about the latest
+        traceback.
+        '''
+        subject = '%s: %s'%(self.config.TRACKER_NAME, sys.exc_info()[1])
+        to = [self.config.ADMIN_EMAIL]
+        content = '\n'.join(traceback.format_exception(*sys.exc_info()))
+        self.standard_message(to, subject, content)
+
+    def smtp_send(self, to, message):
+        """Send a message over SMTP, using roundup's config.
+
+        Arguments:
+        - to: a list of addresses usable by rfc822.parseaddr().
+        - message: a StringIO instance with a full message.
+        """
+        if self.debug:
+            # don't send - just write to a file
+            open(self.debug, 'a').write('FROM: %s\nTO: %s\n%s\n' %
+                                        (self.config.ADMIN_EMAIL,
+                                         ', '.join(to),
+                                         message.getvalue()))
+        else:
+            # now try to send the message
+            try:
+                # send the message as admin so bounces are sent there
+                # instead of to roundup
+                smtp = SMTPConnection(self.config)
+                smtp.sendmail(self.config.ADMIN_EMAIL, to,
+                              message.getvalue())
+            except socket.error, value:
+                raise MessageSendError("Error: couldn't send email: "
+                                       "mailhost %s"%value)
+            except smtplib.SMTPException, msg:
+                raise MessageSendError("Error: couldn't send email: %s"%msg)
+
+class SMTPConnection(smtplib.SMTP):
+    ''' Open an SMTP connection to the mailhost specified in the config
+    '''
+    def __init__(self, config):
+
+        smtplib.SMTP.__init__(self, config.MAILHOST)
+
+        # start the TLS if requested
+        if config["MAIL_TLS"]:
+            self.starttls(config["MAIL_TLS_KEYFILE"],
+                config["MAIL_TLS_CERTFILE"])
+
+        # ok, now do we also need to log in?
+        mailuser = config["MAIL_USERNAME"]
+        if mailuser:
+            self.login(mailuser, config["MAIL_PASSWORD"])
+
+# use the 'email' module, either imported, or our copied version
+try :
+    from email.Utils import formataddr as straddr
+except ImportError :
+    # code taken from the email package 2.4.3
+    def straddr(pair, specialsre = re.compile(r'[][\()<>@,:;".]'),
+            escapesre = re.compile(r'[][\()"]')):
+        name, address = pair
+        if name:
+            quotes = ''
+            if specialsre.search(name):
+                quotes = '"'
+            name = escapesre.sub(r'\\\g<0>', name)
+            return '%s%s%s <%s>' % (quotes, name, quotes, address)
+        return address
+
+# vim: set et sts=4 sw=4 :

Added: tracker/vendor/roundup/current/roundup/mailgw.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/mailgw.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,1237 @@
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+#
+
+"""An e-mail gateway for Roundup.
+
+Incoming messages are examined for multiple parts:
+ . In a multipart/mixed message or part, each subpart is extracted and
+   examined. The text/plain subparts are assembled to form the textual
+   body of the message, to be stored in the file associated with a "msg"
+   class node. Any parts of other types are each stored in separate files
+   and given "file" class nodes that are linked to the "msg" node.
+ . In a multipart/alternative message or part, we look for a text/plain
+   subpart and ignore the other parts.
+
+Summary
+-------
+The "summary" property on message nodes is taken from the first non-quoting
+section in the message body. The message body is divided into sections by
+blank lines. Sections where the second and all subsequent lines begin with
+a ">" or "|" character are considered "quoting sections". The first line of
+the first non-quoting section becomes the summary of the message.
+
+Addresses
+---------
+All of the addresses in the To: and Cc: headers of the incoming message are
+looked up among the user nodes, and the corresponding users are placed in
+the "recipients" property on the new "msg" node. The address in the From:
+header similarly determines the "author" property of the new "msg"
+node. The default handling for addresses that don't have corresponding
+users is to create new users with no passwords and a username equal to the
+address. (The web interface does not permit logins for users with no
+passwords.) If we prefer to reject mail from outside sources, we can simply
+register an auditor on the "user" class that prevents the creation of user
+nodes with no passwords.
+
+Actions
+-------
+The subject line of the incoming message is examined to determine whether
+the message is an attempt to create a new item or to discuss an existing
+item. A designator enclosed in square brackets is sought as the first thing
+on the subject line (after skipping any "Fwd:" or "Re:" prefixes).
+
+If an item designator (class name and id number) is found there, the newly
+created "msg" node is added to the "messages" property for that item, and
+any new "file" nodes are added to the "files" property for the item.
+
+If just an item class name is found there, we attempt to create a new item
+of that class with its "messages" property initialized to contain the new
+"msg" node and its "files" property initialized to contain any new "file"
+nodes.
+
+Triggers
+--------
+Both cases may trigger detectors (in the first case we are calling the
+set() method to add the message to the item's spool; in the second case we
+are calling the create() method to create a new node). If an auditor raises
+an exception, the original message is bounced back to the sender with the
+explanatory message given in the exception.
+
+$Id: mailgw.py,v 1.175 2006/04/06 06:01:35 a1s Exp $
+"""
+__docformat__ = 'restructuredtext'
+
+import string, re, os, mimetools, cStringIO, smtplib, socket, binascii, quopri
+import time, random, sys, logging
+import traceback, MimeWriter, rfc822
+
+from roundup import hyperdb, date, password, rfc2822, exceptions
+from roundup.mailer import Mailer, MessageSendError
+from roundup.i18n import _
+
+SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
+
+class MailGWError(ValueError):
+    pass
+
+class MailUsageError(ValueError):
+    pass
+
+class MailUsageHelp(Exception):
+    """ We need to send the help message to the user. """
+    pass
+
+class Unauthorized(Exception):
+    """ Access denied """
+    pass
+
+class IgnoreMessage(Exception):
+    """ A general class of message that we should ignore. """
+    pass
+class IgnoreBulk(IgnoreMessage):
+        """ This is email from a mailing list or from a vacation program. """
+        pass
+class IgnoreLoop(IgnoreMessage):
+        """ We've seen this message before... """
+        pass
+
+def initialiseSecurity(security):
+    ''' Create some Permissions and Roles on the security object
+
+        This function is directly invoked by security.Security.__init__()
+        as a part of the Security object instantiation.
+    '''
+    p = security.addPermission(name="Email Access",
+        description="User may use the email interface")
+    security.addPermissionToRole('Admin', p)
+
+def getparam(str, param):
+    ''' From the rfc822 "header" string, extract "param" if it appears.
+    '''
+    if ';' not in str:
+        return None
+    str = str[str.index(';'):]
+    while str[:1] == ';':
+        str = str[1:]
+        if ';' in str:
+            # XXX Should parse quotes!
+            end = str.index(';')
+        else:
+            end = len(str)
+        f = str[:end]
+        if '=' in f:
+            i = f.index('=')
+            if f[:i].strip().lower() == param:
+                return rfc822.unquote(f[i+1:].strip())
+    return None
+
+class Message(mimetools.Message):
+    ''' subclass mimetools.Message so we can retrieve the parts of the
+        message...
+    '''
+    def getpart(self):
+        ''' Get a single part of a multipart message and return it as a new
+            Message instance.
+        '''
+        boundary = self.getparam('boundary')
+        mid, end = '--'+boundary, '--'+boundary+'--'
+        s = cStringIO.StringIO()
+        while 1:
+            line = self.fp.readline()
+            if not line:
+                break
+            if line.strip() in (mid, end):
+                break
+            s.write(line)
+        if not s.getvalue().strip():
+            return None
+        s.seek(0)
+        return Message(s)
+
+    def getparts(self):
+        """Get all parts of this multipart message."""
+        # skip over the intro to the first boundary
+        self.getpart()
+
+        # accumulate the other parts
+        parts = []
+        while 1:
+            part = self.getpart()
+            if part is None:
+                break
+            parts.append(part)
+        return parts
+
+    def getheader(self, name, default=None):
+        hdr = mimetools.Message.getheader(self, name, default)
+        if hdr:
+            hdr = hdr.replace('\n','') # Inserted by rfc822.readheaders
+        return rfc2822.decode_header(hdr)
+
+    def getname(self):
+        """Find an appropriate name for this message."""
+        if self.gettype() == 'message/rfc822':
+            # handle message/rfc822 specially - the name should be
+            # the subject of the actual e-mail embedded here
+            self.fp.seek(0)
+            name = Message(self.fp).getheader('subject')
+        else:
+            # try name on Content-Type
+            name = self.getparam('name')
+            if not name:
+                disp = self.getheader('content-disposition', None)
+                if disp:
+                    name = getparam(disp, 'filename')
+
+        if name:
+            return name.strip()
+
+    def getbody(self):
+        """Get the decoded message body."""
+        self.rewindbody()
+        encoding = self.getencoding()
+        data = None
+        if encoding == 'base64':
+            # BUG: is base64 really used for text encoding or
+            # are we inserting zip files here.
+            data = binascii.a2b_base64(self.fp.read())
+        elif encoding == 'quoted-printable':
+            # the quopri module wants to work with files
+            decoded = cStringIO.StringIO()
+            quopri.decode(self.fp, decoded)
+            data = decoded.getvalue()
+        elif encoding == 'uuencoded':
+            data = binascii.a2b_uu(self.fp.read())
+        else:
+            # take it as text
+            data = self.fp.read()
+
+        # Encode message to unicode
+        charset = rfc2822.unaliasCharset(self.getparam("charset"))
+        if charset:
+            # Do conversion only if charset specified - handle
+            # badly-specified charsets
+            edata = unicode(data, charset, 'replace').encode('utf-8')
+            # Convert from dos eol to unix
+            edata = edata.replace('\r\n', '\n')
+        else:
+            # Leave message content as is
+            edata = data
+
+        return edata
+
+    # General multipart handling:
+    #   Take the first text/plain part, anything else is considered an
+    #   attachment.
+    # multipart/mixed:
+    #   Multiple "unrelated" parts.
+    # multipart/Alternative (rfc 1521):
+    #   Like multipart/mixed, except that we'd only want one of the
+    #   alternatives. Generally a top-level part from MUAs sending HTML
+    #   mail - there will be a text/plain version.
+    # multipart/signed (rfc 1847):
+    #   The control information is carried in the second of the two
+    #   required body parts.
+    #   ACTION: Default, so if content is text/plain we get it.
+    # multipart/encrypted (rfc 1847):
+    #   The control information is carried in the first of the two
+    #   required body parts.
+    #   ACTION: Not handleable as the content is encrypted.
+    # multipart/related (rfc 1872, 2112, 2387):
+    #   The Multipart/Related content-type addresses the MIME
+    #   representation of compound objects, usually HTML mail with embedded
+    #   images. Usually appears as an alternative.
+    #   ACTION: Default, if we must.
+    # multipart/report (rfc 1892):
+    #   e.g. mail system delivery status reports.
+    #   ACTION: Default. Could be ignored or used for Delivery Notification
+    #   flagging.
+    # multipart/form-data:
+    #   For web forms only.
+
+    def extract_content(self, parent_type=None):
+        """Extract the body and the attachments recursively."""
+        content_type = self.gettype()
+        content = None
+        attachments = []
+
+        if content_type == 'text/plain':
+            content = self.getbody()
+        elif content_type[:10] == 'multipart/':
+            for part in self.getparts():
+                new_content, new_attach = part.extract_content(content_type)
+
+                # If we haven't found a text/plain part yet, take this one,
+                # otherwise make it an attachment.
+                if not content:
+                    content = new_content
+                elif new_content:
+                    attachments.append(part.as_attachment())
+
+                attachments.extend(new_attach)
+        elif (parent_type == 'multipart/signed' and
+              content_type == 'application/pgp-signature'):
+            # ignore it so it won't be saved as an attachment
+            pass
+        else:
+            attachments.append(self.as_attachment())
+        return content, attachments
+
+    def as_attachment(self):
+        """Return this message as an attachment."""
+        return (self.getname(), self.gettype(), self.getbody())
+
+class MailGW:
+
+    def __init__(self, instance, db, arguments=()):
+        self.instance = instance
+        self.db = db
+        self.arguments = arguments
+        self.default_class = None
+        for option, value in self.arguments:
+            if option == '-c':
+                self.default_class = value.strip()
+
+        self.mailer = Mailer(instance.config)
+        self.logger = logging.getLogger('mailgw')
+
+        # should we trap exceptions (normal usage) or pass them through
+        # (for testing)
+        self.trapExceptions = 1
+
+    def do_pipe(self):
+        """ Read a message from standard input and pass it to the mail handler.
+
+            Read into an internal structure that we can seek on (in case
+            there's an error).
+
+            XXX: we may want to read this into a temporary file instead...
+        """
+        s = cStringIO.StringIO()
+        s.write(sys.stdin.read())
+        s.seek(0)
+        self.main(s)
+        return 0
+
+    def do_mailbox(self, filename):
+        """ Read a series of messages from the specified unix mailbox file and
+            pass each to the mail handler.
+        """
+        # open the spool file and lock it
+        import fcntl
+        # FCNTL is deprecated in py2.3 and fcntl takes over all the symbols
+        if hasattr(fcntl, 'LOCK_EX'):
+            FCNTL = fcntl
+        else:
+            import FCNTL
+        f = open(filename, 'r+')
+        fcntl.flock(f.fileno(), FCNTL.LOCK_EX)
+
+        # handle and clear the mailbox
+        try:
+            from mailbox import UnixMailbox
+            mailbox = UnixMailbox(f, factory=Message)
+            # grab one message
+            message = mailbox.next()
+            while message:
+                # handle this message
+                self.handle_Message(message)
+                message = mailbox.next()
+            # nuke the file contents
+            os.ftruncate(f.fileno(), 0)
+        except:
+            import traceback
+            traceback.print_exc()
+            return 1
+        fcntl.flock(f.fileno(), FCNTL.LOCK_UN)
+        return 0
+
+    def do_imap(self, server, user='', password='', mailbox='', ssl=0):
+        ''' Do an IMAP connection
+        '''
+        import getpass, imaplib, socket
+        try:
+            if not user:
+                user = raw_input('User: ')
+            if not password:
+                password = getpass.getpass()
+        except (KeyboardInterrupt, EOFError):
+            # Ctrl C or D maybe also Ctrl Z under Windows.
+            print "\nAborted by user."
+            return 1
+        # open a connection to the server and retrieve all messages
+        try:
+            if ssl:
+                self.logger.debug('Trying server %r with ssl'%server)
+                server = imaplib.IMAP4_SSL(server)
+            else:
+                self.logger.debug('Trying server %r without ssl'%server)
+                server = imaplib.IMAP4(server)
+        except (imaplib.IMAP4.error, socket.error, socket.sslerror):
+            self.logger.exception('IMAP server error')
+            return 1
+
+        try:
+            server.login(user, password)
+        except imaplib.IMAP4.error, e:
+            self.logger.exception('IMAP login failure')
+            return 1
+
+        try:
+            if not mailbox:
+                (typ, data) = server.select()
+            else:
+                (typ, data) = server.select(mailbox=mailbox)
+            if typ != 'OK':
+                self.logger.error('Failed to get mailbox %r: %s'%(mailbox,
+                    data))
+                return 1
+            try:
+                numMessages = int(data[0])
+            except ValueError, value:
+                self.logger.error('Invalid message count from mailbox %r'%
+                    data[0])
+                return 1
+            for i in range(1, numMessages+1):
+                (typ, data) = server.fetch(str(i), '(RFC822)')
+
+                # mark the message as deleted.
+                server.store(str(i), '+FLAGS', r'(\Deleted)')
+
+                # process the message
+                s = cStringIO.StringIO(data[0][1])
+                s.seek(0)
+                self.handle_Message(Message(s))
+            server.close()
+        finally:
+            try:
+                server.expunge()
+            except:
+                pass
+            server.logout()
+
+        return 0
+
+
+    def do_apop(self, server, user='', password=''):
+        ''' Do authentication POP
+        '''
+        self.do_pop(server, user, password, apop=1)
+
+    def do_pop(self, server, user='', password='', apop=0):
+        '''Read a series of messages from the specified POP server.
+        '''
+        import getpass, poplib, socket
+        try:
+            if not user:
+                user = raw_input('User: ')
+            if not password:
+                password = getpass.getpass()
+        except (KeyboardInterrupt, EOFError):
+            # Ctrl C or D maybe also Ctrl Z under Windows.
+            print "\nAborted by user."
+            return 1
+
+        # open a connection to the server and retrieve all messages
+        try:
+            server = poplib.POP3(server)
+        except socket.error:
+            self.logger.exception('POP server error')
+            return 1
+        if apop:
+            server.apop(user, password)
+        else:
+            server.user(user)
+            server.pass_(password)
+        numMessages = len(server.list()[1])
+        for i in range(1, numMessages+1):
+            # retr: returns
+            # [ pop response e.g. '+OK 459 octets',
+            #   [ array of message lines ],
+            #   number of octets ]
+            lines = server.retr(i)[1]
+            s = cStringIO.StringIO('\n'.join(lines))
+            s.seek(0)
+            self.handle_Message(Message(s))
+            # delete the message
+            server.dele(i)
+
+        # quit the server to commit changes.
+        server.quit()
+        return 0
+
+    def main(self, fp):
+        ''' fp - the file from which to read the Message.
+        '''
+        return self.handle_Message(Message(fp))
+
+    def handle_Message(self, message):
+        """Handle an RFC822 Message
+
+        Handle the Message object by calling handle_message() and then cope
+        with any errors raised by handle_message.
+        This method's job is to make that call and handle any
+        errors in a sane manner. It should be replaced if you wish to
+        handle errors in a different manner.
+        """
+        # in some rare cases, a particularly stuffed-up e-mail will make
+        # its way into here... try to handle it gracefully
+
+        sendto = message.getaddrlist('resent-from')
+        if not sendto:
+            sendto = message.getaddrlist('from')
+        if not sendto:
+            # very bad-looking message - we don't even know who sent it
+            msg = ['Badly formed message from mail gateway. Headers:']
+            msg.extend(message.headers)
+            msg = '\n'.join(map(str, msg))
+            self.logger.error(msg)
+            return
+
+        msg = 'Handling message'
+        if message.getheader('message-id'):
+            msg += ' (Message-id=%r)'%message.getheader('message-id')
+        self.logger.info(msg)
+
+        # try normal message-handling
+        if not self.trapExceptions:
+            return self.handle_message(message)
+
+        # no, we want to trap exceptions
+        try:
+            return self.handle_message(message)
+        except MailUsageHelp:
+            # bounce the message back to the sender with the usage message
+            fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
+            m = ['']
+            m.append('\n\nMail Gateway Help\n=================')
+            m.append(fulldoc)
+            self.mailer.bounce_message(message, [sendto[0][1]], m,
+                subject="Mail Gateway Help")
+        except MailUsageError, value:
+            # bounce the message back to the sender with the usage message
+            fulldoc = '\n'.join(string.split(__doc__, '\n')[2:])
+            m = ['']
+            m.append(str(value))
+            m.append('\n\nMail Gateway Help\n=================')
+            m.append(fulldoc)
+            self.mailer.bounce_message(message, [sendto[0][1]], m)
+        except Unauthorized, value:
+            # just inform the user that he is not authorized
+            m = ['']
+            m.append(str(value))
+            try:
+                self.mailer.bounce_message(message, [sendto[0][1]], m)
+            except MessageSendError, error:
+                # if the only reason the bounce failed is because of
+                # invalid addresses to bounce the message back to, then
+                # just discard the message - it's just not worth bothering
+                # with (most likely spam / otherwise forged)
+                invalid = True
+                for address, (code, reason) in error.keys():
+                    if code != 550:
+                        invalid = False
+                if not invalid:
+                    raise
+        except IgnoreMessage:
+            # do not take any action
+            # this exception is thrown when email should be ignored
+            msg = 'IgnoreMessage raised'
+            if message.getheader('message-id'):
+                msg += ' (Message-id=%r)'%message.getheader('message-id')
+            self.logger.info(msg)
+            return
+        except:
+            msg = 'Exception handling message'
+            if message.getheader('message-id'):
+                msg += ' (Message-id=%r)'%message.getheader('message-id')
+            self.logger.exception(msg)
+
+            # bounce the message back to the sender with the error message
+            # let the admin know that something very bad is happening
+            sendto = [sendto[0][1], self.instance.config.ADMIN_EMAIL]
+            m = ['']
+            m.append('An unexpected error occurred during the processing')
+            m.append('of your message. The tracker administrator is being')
+            m.append('notified.\n')
+            self.mailer.bounce_message(message, sendto, m)
+
+    def handle_message(self, message):
+        ''' message - a Message instance
+
+        Parse the message as per the module docstring.
+        '''
+        # detect loops
+        if message.getheader('x-roundup-loop', ''):
+            raise IgnoreLoop
+
+        # handle the subject line
+        subject = message.getheader('subject', '')
+        if not subject:
+            raise MailUsageError, _("""
+Emails to Roundup trackers must include a Subject: line!
+""")
+
+        # detect Precedence: Bulk, or Microsoft Outlook autoreplies
+        if (message.getheader('precedence', '') == 'bulk'
+                or subject.lower().find("autoreply") > 0):
+            raise IgnoreBulk
+
+        if subject.strip().lower() == 'help':
+            raise MailUsageHelp
+
+        # config is used many times in this method.
+        # make local variable for easier access
+        config = self.instance.config
+
+        # determine the sender's address
+        from_list = message.getaddrlist('resent-from')
+        if not from_list:
+            from_list = message.getaddrlist('from')
+
+        # XXX Don't enable. This doesn't work yet.
+#  "[^A-z.]tracker\+(?P<classname>[^\d\s]+)(?P<nodeid>\d+)\@some.dom.ain[^A-z.]"
+        # handle delivery to addresses like:tracker+issue25 at some.dom.ain
+        # use the embedded issue number as our issue
+#        issue_re = config['MAILGW_ISSUE_ADDRESS_RE']
+#        if issue_re:
+#            for header in ['to', 'cc', 'bcc']:
+#                addresses = message.getheader(header, '')
+#            if addresses:
+#              # FIXME, this only finds the first match in the addresses.
+#                issue = re.search(issue_re, addresses, 'i')
+#                if issue:
+#                    classname = issue.group('classname')
+#                    nodeid = issue.group('nodeid')
+#                    break
+
+        # Matches subjects like:
+        # Re: "[issue1234] title of issue [status=resolved]"
+        open, close = config['MAILGW_SUBJECT_SUFFIX_DELIMITERS']
+        delim_open = re.escape(open)
+        delim_close = re.escape(close)
+        subject_re = re.compile(r'''
+        (?P<refwd>\s*\W?\s*(fw|fwd|re|aw)\W\s*)*\s*   # Re:
+        (?P<quote>")?                                 # Leading "
+        (\[(?P<classname>[^\d\s]+)                    # [issue..
+           (?P<nodeid>\d+)?                           # ..1234]
+         \])?\s*
+        (?P<title>[^%s]+)?                             # issue title
+        "?                                            # Trailing "
+        (?P<argswhole>%s(?P<args>.+?)%s)?             # [prop=value]
+        '''%(delim_open, delim_open, delim_close),
+        re.IGNORECASE|re.VERBOSE)
+
+        # figure subject line parsing modes
+        pfxmode = config['MAILGW_SUBJECT_PREFIX_PARSING']
+        sfxmode = config['MAILGW_SUBJECT_SUFFIX_PARSING']
+
+        # check for well-formed subject line
+        m = subject_re.match(subject)
+        if m:
+            # check for registration OTK
+            # or fallback on the default class
+            if self.db.config['EMAIL_REGISTRATION_CONFIRMATION']:
+                otk_re = re.compile('-- key (?P<otk>[a-zA-Z0-9]{32})')
+                otk = otk_re.search(m.group('title') or '')
+                if otk:
+                    self.db.confirm_registration(otk.group('otk'))
+                    subject = 'Your registration to %s is complete' % \
+                              config['TRACKER_NAME']
+                    sendto = [from_list[0][1]]
+                    self.mailer.standard_message(sendto, subject, '')
+                    return
+            # get the classname
+            if pfxmode == 'none':
+                classname = None
+            else:
+                classname = m.group('classname')
+            if classname is None:
+                if self.default_class:
+                    classname = self.default_class
+                else:
+                    classname = config['MAILGW_DEFAULT_CLASS']
+                    if not classname:
+                        # fail
+                        m = None
+
+        if not m and pfxmode == 'strict':
+            raise MailUsageError, _("""
+The message you sent to roundup did not contain a properly formed subject
+line. The subject must contain a class name or designator to indicate the
+'topic' of the message. For example:
+    Subject: [issue] This is a new issue
+      - this will create a new issue in the tracker with the title 'This is
+        a new issue'.
+    Subject: [issue1234] This is a followup to issue 1234
+      - this will append the message's contents to the existing issue 1234
+        in the tracker.
+
+Subject was: '%(subject)s'
+""") % locals()
+
+        # try to get the class specified - if "loose" then fall back on the
+        # default
+        attempts = [classname]
+        if pfxmode == 'loose':
+            if self.default_class:
+                attempts.append(self.default_class)
+            else:
+                attempts.append(config['MAILGW_DEFAULT_CLASS'])
+        cl = None
+        for trycl in attempts:
+            try:
+                cl = self.db.getclass(classname)
+                break
+            except KeyError:
+                pass
+        if not cl:
+            validname = ', '.join(self.db.getclasses())
+            raise MailUsageError, _("""
+The class name you identified in the subject line ("%(classname)s") does not exist in the
+database.
+
+Valid class names are: %(validname)s
+Subject was: "%(subject)s"
+""") % locals()
+
+        # get the optional nodeid
+        if pfxmode == 'none':
+            nodeid = None
+        else:
+            nodeid = m.group('nodeid')
+
+        # title is optional too
+        title = m.group('title')
+        if title:
+            title = title.strip()
+        else:
+            title = ''
+
+        # strip off the quotes that dumb emailers put around the subject, like
+        #      Re: "[issue1] bla blah"
+        if m.group('quote') and title.endswith('"'):
+            title = title[:-1]
+
+        # but we do need either a title or a nodeid...
+        if nodeid is None and not title:
+            raise MailUsageError, _("""
+I cannot match your message to a node in the database - you need to either
+supply a full designator (with number, eg "[issue123]" or keep the
+previous subject title intact so I can match that.
+
+Subject was: "%(subject)s"
+""") % locals()
+
+        # If there's no nodeid, check to see if this is a followup and
+        # maybe someone's responded to the initial mail that created an
+        # entry. Try to find the matching nodes with the same title, and
+        # use the _last_ one matched (since that'll _usually_ be the most
+        # recent...). The subject_content_match config may specify an
+        # additional restriction based on the matched node's creation or
+        # activity.
+        tmatch_mode = config['MAILGW_SUBJECT_CONTENT_MATCH']
+        if tmatch_mode != 'never' and nodeid is None and m.group('refwd'):
+            l = cl.stringFind(title=title)
+            limit = None
+            if (tmatch_mode.startswith('creation') or
+                    tmatch_mode.startswith('activity')):
+                limit, interval = tmatch_mode.split(' ', 1)
+                threshold = date.Date('.') - date.Interval(interval)
+            for id in l:
+                if limit:
+                    if threshold < cl.get(id, limit):
+                        nodeid = id
+                else:
+                    nodeid = id
+
+        # if a nodeid was specified, make sure it's valid
+        if nodeid is not None and not cl.hasnode(nodeid):
+            if pfxmode == 'strict':
+                raise MailUsageError, _("""
+The node specified by the designator in the subject of your message
+("%(nodeid)s") does not exist.
+
+Subject was: "%(subject)s"
+""") % locals()
+            else:
+                title = subject
+                nodeid = None
+
+        # Handle the arguments specified by the email gateway command line.
+        # We do this by looping over the list of self.arguments looking for
+        # a -C to tell us what class then the -S setting string.
+        msg_props = {}
+        user_props = {}
+        file_props = {}
+        issue_props = {}
+        # so, if we have any arguments, use them
+        if self.arguments:
+            current_class = 'msg'
+            for option, propstring in self.arguments:
+                if option in ( '-C', '--class'):
+                    current_class = propstring.strip()
+                    # XXX this is not flexible enough.
+                    #   we should chect for subclasses of these classes,
+                    #   not for the class name...
+                    if current_class not in ('msg', 'file', 'user', 'issue'):
+                        mailadmin = config['ADMIN_EMAIL']
+                        raise MailUsageError, _("""
+The mail gateway is not properly set up. Please contact
+%(mailadmin)s and have them fix the incorrect class specified as:
+  %(current_class)s
+""") % locals()
+                if option in ('-S', '--set'):
+                    if current_class == 'issue' :
+                        errors, issue_props = setPropArrayFromString(self,
+                            cl, propstring.strip(), nodeid)
+                    elif current_class == 'file' :
+                        temp_cl = self.db.getclass('file')
+                        errors, file_props = setPropArrayFromString(self,
+                            temp_cl, propstring.strip())
+                    elif current_class == 'msg' :
+                        temp_cl = self.db.getclass('msg')
+                        errors, msg_props = setPropArrayFromString(self,
+                            temp_cl, propstring.strip())
+                    elif current_class == 'user' :
+                        temp_cl = self.db.getclass('user')
+                        errors, user_props = setPropArrayFromString(self,
+                            temp_cl, propstring.strip())
+                    if errors:
+                        mailadmin = config['ADMIN_EMAIL']
+                        raise MailUsageError, _("""
+The mail gateway is not properly set up. Please contact
+%(mailadmin)s and have them fix the incorrect properties:
+  %(errors)s
+""") % locals()
+
+        #
+        # handle the users
+        #
+        # Don't create users if anonymous isn't allowed to register
+        create = 1
+        anonid = self.db.user.lookup('anonymous')
+        if not (self.db.security.hasPermission('Create', anonid, 'user')
+                and self.db.security.hasPermission('Email Access', anonid)):
+            create = 0
+
+        # ok, now figure out who the author is - create a new user if the
+        # "create" flag is true
+        author = uidFromAddress(self.db, from_list[0], create=create)
+
+        # if we're not recognised, and we don't get added as a user, then we
+        # must be anonymous
+        if not author:
+            author = anonid
+
+        # make sure the author has permission to use the email interface
+        if not self.db.security.hasPermission('Email Access', author):
+            if author == anonid:
+                # we're anonymous and we need to be a registered user
+                from_address = from_list[0][1]
+                raise Unauthorized, _("""
+You are not a registered user.
+
+Unknown address: %(from_address)s
+""") % locals()
+            else:
+                # we're registered and we're _still_ not allowed access
+                raise Unauthorized, _(
+                    'You are not permitted to access this tracker.')
+
+        # make sure they're allowed to edit or create this class of information
+        if nodeid:
+            if not self.db.security.hasPermission('Edit', author, classname,
+                    itemid=nodeid):
+                raise Unauthorized, _(
+                    'You are not permitted to edit %(classname)s.') % locals()
+        else:
+            if not self.db.security.hasPermission('Create', author, classname):
+                raise Unauthorized, _(
+                    'You are not permitted to create %(classname)s.'
+                    ) % locals()
+
+        # the author may have been created - make sure the change is
+        # committed before we reopen the database
+        self.db.commit()
+
+        # set the database user as the author
+        username = self.db.user.get(author, 'username')
+        self.db.setCurrentUser(username)
+
+        # re-get the class with the new database connection
+        cl = self.db.getclass(classname)
+
+        # now update the recipients list
+        recipients = []
+        tracker_email = config['TRACKER_EMAIL'].lower()
+        for recipient in message.getaddrlist('to') + message.getaddrlist('cc'):
+            r = recipient[1].strip().lower()
+            if r == tracker_email or not r:
+                continue
+
+            # look up the recipient - create if necessary (and we're
+            # allowed to)
+            recipient = uidFromAddress(self.db, recipient, create, **user_props)
+
+            # if all's well, add the recipient to the list
+            if recipient:
+                recipients.append(recipient)
+
+        #
+        # handle the subject argument list
+        #
+        # figure what the properties of this Class are
+        properties = cl.getprops()
+        props = {}
+        args = m.group('args')
+        argswhole = m.group('argswhole')
+        if args:
+            if sfxmode == 'none':
+                title += ' ' + argswhole
+            else:
+                errors, props = setPropArrayFromString(self, cl, args, nodeid)
+                # handle any errors parsing the argument list
+                if errors:
+                    if sfxmode == 'strict':
+                        errors = '\n- '.join(map(str, errors))
+                        raise MailUsageError, _("""
+There were problems handling your subject line argument list:
+- %(errors)s
+
+Subject was: "%(subject)s"
+""") % locals()
+                    else:
+                        title += ' ' + argswhole
+
+
+        # set the issue title to the subject
+        title = title.strip()
+        if (title and properties.has_key('title') and not
+                issue_props.has_key('title')):
+            issue_props['title'] = title
+
+        #
+        # handle message-id and in-reply-to
+        #
+        messageid = message.getheader('message-id')
+        inreplyto = message.getheader('in-reply-to') or ''
+        # generate a messageid if there isn't one
+        if not messageid:
+            messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
+                classname, nodeid, config['MAIL_DOMAIN'])
+
+        # now handle the body - find the message
+        content, attachments = message.extract_content()
+        if content is None:
+            raise MailUsageError, _("""
+Roundup requires the submission to be plain text. The message parser could
+not find a text/plain part to use.
+""")
+
+        # figure how much we should muck around with the email body
+        keep_citations = config['MAILGW_KEEP_QUOTED_TEXT']
+        keep_body = config['MAILGW_LEAVE_BODY_UNCHANGED']
+
+        # parse the body of the message, stripping out bits as appropriate
+        summary, content = parseContent(content, keep_citations,
+            keep_body)
+        content = content.strip()
+
+        #
+        # handle the attachments
+        #
+        if properties.has_key('files'):
+            files = []
+            for (name, mime_type, data) in attachments:
+                if not self.db.security.hasPermission('Create', author, 'file'):
+                    raise Unauthorized, _(
+                        'You are not permitted to create files.')
+                if not name:
+                    name = "unnamed"
+                try:
+                    fileid = self.db.file.create(type=mime_type, name=name,
+                         content=data, **file_props)
+                except exceptions.Reject:
+                    pass
+                else:
+                    files.append(fileid)
+            # attach the files to the issue
+            if not self.db.security.hasPermission('Edit', author,
+                    classname, 'files'):
+                raise Unauthorized, _(
+                    'You are not permitted to add files to %(classname)s.'
+                    ) % locals()
+
+            if nodeid:
+                # extend the existing files list
+                fileprop = cl.get(nodeid, 'files')
+                fileprop.extend(files)
+                props['files'] = fileprop
+            else:
+                # pre-load the files list
+                props['files'] = files
+
+        #
+        # create the message if there's a message body (content)
+        #
+        if (content and properties.has_key('messages')):
+            if not self.db.security.hasPermission('Create', author, 'msg'):
+                raise Unauthorized, _(
+                    'You are not permitted to create messages.')
+
+            try:
+                message_id = self.db.msg.create(author=author,
+                    recipients=recipients, date=date.Date('.'),
+                    summary=summary, content=content, files=files,
+                    messageid=messageid, inreplyto=inreplyto, **msg_props)
+            except exceptions.Reject, error:
+                raise MailUsageError, _("""
+Mail message was rejected by a detector.
+%(error)s
+""") % locals()
+            # attach the message to the node
+            if not self.db.security.hasPermission('Edit', author,
+                    classname, 'messages'):
+                raise Unauthorized, _(
+                    'You are not permitted to add messages to %(classname)s.'
+                    ) % locals()
+
+            if nodeid:
+                # add the message to the node's list
+                messages = cl.get(nodeid, 'messages')
+                messages.append(message_id)
+                props['messages'] = messages
+            else:
+                # pre-load the messages list
+                props['messages'] = [message_id]
+
+        #
+        # perform the node change / create
+        #
+        try:
+            # merge the command line props defined in issue_props into
+            # the props dictionary because function(**props, **issue_props)
+            # is a syntax error.
+            for prop in issue_props.keys() :
+                if not props.has_key(prop) :
+                    props[prop] = issue_props[prop]
+
+            # Check permissions for each property
+            for prop in props.keys():
+                if not self.db.security.hasPermission('Edit', author,
+                        classname, prop):
+                    raise Unauthorized, _('You are not permitted to edit '
+                        'property %(prop)s of class %(classname)s.') % locals()
+
+            if nodeid:
+                cl.set(nodeid, **props)
+            else:
+                nodeid = cl.create(**props)
+        except (TypeError, IndexError, ValueError), message:
+            raise MailUsageError, _("""
+There was a problem with the message you sent:
+   %(message)s
+""") % locals()
+
+        # commit the changes to the DB
+        self.db.commit()
+
+        return nodeid
+
+
+def setPropArrayFromString(self, cl, propString, nodeid=None):
+    ''' takes string of form prop=value,value;prop2=value
+        and returns (error, prop[..])
+    '''
+    props = {}
+    errors = []
+    for prop in string.split(propString, ';'):
+        # extract the property name and value
+        try:
+            propname, value = prop.split('=')
+        except ValueError, message:
+            errors.append(_('not of form [arg=value,value,...;'
+                'arg=value,value,...]'))
+            return (errors, props)
+        # convert the value to a hyperdb-usable value
+        propname = propname.strip()
+        try:
+            props[propname] = hyperdb.rawToHyperdb(self.db, cl, nodeid,
+                propname, value)
+        except hyperdb.HyperdbValueError, message:
+            errors.append(str(message))
+    return errors, props
+
+
+def extractUserFromList(userClass, users):
+    '''Given a list of users, try to extract the first non-anonymous user
+       and return that user, otherwise return None
+    '''
+    if len(users) > 1:
+        for user in users:
+            # make sure we don't match the anonymous or admin user
+            if userClass.get(user, 'username') in ('admin', 'anonymous'):
+                continue
+            # first valid match will do
+            return user
+        # well, I guess we have no choice
+        return user[0]
+    elif users:
+        return users[0]
+    return None
+
+
+def uidFromAddress(db, address, create=1, **user_props):
+    ''' address is from the rfc822 module, and therefore is (name, addr)
+
+        user is created if they don't exist in the db already
+        user_props may supply additional user information
+    '''
+    (realname, address) = address
+
+    # try a straight match of the address
+    user = extractUserFromList(db.user, db.user.stringFind(address=address))
+    if user is not None:
+        return user
+
+    # try the user alternate addresses if possible
+    props = db.user.getprops()
+    if props.has_key('alternate_addresses'):
+        users = db.user.filter(None, {'alternate_addresses': address})
+        user = extractUserFromList(db.user, users)
+        if user is not None:
+            return user
+
+    # try to match the username to the address (for local
+    # submissions where the address is empty)
+    user = extractUserFromList(db.user, db.user.stringFind(username=address))
+
+    # couldn't match address or username, so create a new user
+    if create:
+        # generate a username
+        if '@' in address:
+            username = address.split('@')[0]
+        else:
+            username = address
+        trying = username
+        n = 0
+        while 1:
+            try:
+                # does this username exist already?
+                db.user.lookup(trying)
+            except KeyError:
+                break
+            n += 1
+            trying = username + str(n)
+
+        # create!
+        try:
+            return db.user.create(username=trying, address=address,
+                realname=realname, roles=db.config.NEW_EMAIL_USER_ROLES,
+                password=password.Password(password.generatePassword()),
+                **user_props)
+        except exceptions.Reject:
+            return 0
+    else:
+        return 0
+
+
+def parseContent(content, keep_citations, keep_body,
+        blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'),
+        eol=re.compile(r'[\r\n]+'),
+        signature=re.compile(r'^[>|\s]*-- ?$'),
+        original_msg=re.compile(r'^[>|\s]*-----\s?Original Message\s?-----$')):
+    ''' The message body is divided into sections by blank lines.
+        Sections where the second and all subsequent lines begin with a ">"
+        or "|" character are considered "quoting sections". The first line of
+        the first non-quoting section becomes the summary of the message.
+
+        If keep_citations is true, then we keep the "quoting sections" in the
+        content.
+        If keep_body is true, we even keep the signature sections.
+    '''
+    # strip off leading carriage-returns / newlines
+    i = 0
+    for i in range(len(content)):
+        if content[i] not in '\r\n':
+            break
+    if i > 0:
+        sections = blank_line.split(content[i:])
+    else:
+        sections = blank_line.split(content)
+
+    # extract out the summary from the message
+    summary = ''
+    l = []
+    for section in sections:
+        #section = section.strip()
+        if not section:
+            continue
+        lines = eol.split(section)
+        if (lines[0] and lines[0][0] in '>|') or (len(lines) > 1 and
+                lines[1] and lines[1][0] in '>|'):
+            # see if there's a response somewhere inside this section (ie.
+            # no blank line between quoted message and response)
+            for line in lines[1:]:
+                if line and line[0] not in '>|':
+                    break
+            else:
+                # we keep quoted bits if specified in the config
+                if keep_citations:
+                    l.append(section)
+                continue
+            # keep this section - it has reponse stuff in it
+            lines = lines[lines.index(line):]
+            section = '\n'.join(lines)
+            # and while we're at it, use the first non-quoted bit as
+            # our summary
+            summary = section
+
+        if not summary:
+            # if we don't have our summary yet use the first line of this
+            # section
+            summary = section
+        elif signature.match(lines[0]) and 2 <= len(lines) <= 10:
+            # lose any signature
+            break
+        elif original_msg.match(lines[0]):
+            # ditch the stupid Outlook quoting of the entire original message
+            break
+
+        # and add the section to the output
+        l.append(section)
+
+    # figure the summary - find the first sentence-ending punctuation or the
+    # first whole line, whichever is longest
+    sentence = re.search(r'^([^!?\.]+[!?\.])', summary)
+    if sentence:
+        sentence = sentence.group(1)
+    else:
+        sentence = ''
+    first = eol.split(summary)[0]
+    summary = max(sentence, first)
+
+    # Now reconstitute the message content minus the bits we don't care
+    # about.
+    if not keep_body:
+        content = '\n\n'.join(l)
+
+    return summary, content
+
+# vim: set filetype=python sts=4 sw=4 et si :

Added: tracker/vendor/roundup/current/roundup/msgfmt.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/msgfmt.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,224 @@
+#! /usr/bin/env python
+# -*- coding: iso-8859-1 -*-
+# Written by Martin v. Löwis <loewis at informatik.hu-berlin.de>
+# Plural forms support added by alexander smishlajev <alex at tycobka.lv>
+
+"""Generate binary message catalog from textual translation description.
+
+This program converts a textual Uniforum-style message catalog (.po file) into
+a binary GNU catalog (.mo file).  This is essentially the same function as the
+GNU msgfmt program, however, it is a simpler implementation.
+
+Usage: msgfmt.py [OPTIONS] filename.po
+
+Options:
+    -o file
+    --output-file=file
+        Specify the output file to write to.  If omitted, output will go to a
+        file named filename.mo (based off the input file name).
+
+    -h
+    --help
+        Print this message and exit.
+
+    -V
+    --version
+        Display version information and exit.
+"""
+
+import sys
+import os
+import getopt
+import struct
+import array
+
+__version__ = "1.1"
+
+MESSAGES = {}
+
+
+
+def usage(code, msg=''):
+    print >> sys.stderr, __doc__
+    if msg:
+        print >> sys.stderr, msg
+    sys.exit(code)
+
+
+
+def add(id, str, fuzzy):
+    "Add a non-fuzzy translation to the dictionary."
+    global MESSAGES
+    if not fuzzy and str and not str.startswith('\0'):
+        MESSAGES[id] = str
+
+
+
+def generate():
+    "Return the generated output."
+    global MESSAGES
+    keys = MESSAGES.keys()
+    # the keys are sorted in the .mo file
+    keys.sort()
+    offsets = []
+    ids = strs = ''
+    for id in keys:
+        # For each string, we need size and file offset.  Each string is NUL
+        # terminated; the NUL does not count into the size.
+        offsets.append((len(ids), len(id), len(strs), len(MESSAGES[id])))
+        ids += id + '\0'
+        strs += MESSAGES[id] + '\0'
+    output = ''
+    # The header is 7 32-bit unsigned integers.  We don't use hash tables, so
+    # the keys start right after the index tables.
+    # translated string.
+    keystart = 7*4+16*len(keys)
+    # and the values start after the keys
+    valuestart = keystart + len(ids)
+    koffsets = []
+    voffsets = []
+    # The string table first has the list of keys, then the list of values.
+    # Each entry has first the size of the string, then the file offset.
+    for o1, l1, o2, l2 in offsets:
+        koffsets += [l1, o1+keystart]
+        voffsets += [l2, o2+valuestart]
+    offsets = koffsets + voffsets
+    output = struct.pack("Iiiiiii",
+                         0x950412deL,       # Magic
+                         0,                 # Version
+                         len(keys),         # # of entries
+                         7*4,               # start of key index
+                         7*4+len(keys)*8,   # start of value index
+                         0, 0)              # size and offset of hash table
+    output += array.array("i", offsets).tostring()
+    output += ids
+    output += strs
+    return output
+
+
+
+def make(filename, outfile):
+    ID = 1
+    STR = 2
+    global MESSAGES
+    MESSAGES = {}
+
+    # Compute .mo name from .po name and arguments
+    if filename.endswith('.po'):
+        infile = filename
+    else:
+        infile = filename + '.po'
+    if outfile is None:
+        outfile = os.path.splitext(infile)[0] + '.mo'
+
+    try:
+        lines = open(infile).readlines()
+    except IOError, msg:
+        print >> sys.stderr, msg
+        sys.exit(1)
+
+    # remove UTF-8 Byte Order Mark, if any.
+    # (UCS2 BOMs are not handled because messages in UCS2 cannot be handled)
+    if lines[0].startswith('\xEF\xBB\xBF'):
+        lines[0] = lines[0][3:]
+
+    section = None
+    fuzzy = 0
+
+    # Parse the catalog
+    lno = 0
+    for l in lines:
+        lno += 1
+        # If we get a comment line after a msgstr, this is a new entry
+        if l[0] == '#' and section == STR:
+            add(msgid, msgstr, fuzzy)
+            section = None
+            fuzzy = 0
+        # Record a fuzzy mark
+        if l[:2] == '#,' and (l.find('fuzzy') >= 0):
+            fuzzy = 1
+        # Skip comments
+        if l[0] == '#':
+            continue
+        # Start of msgid_plural section, separate from singular form with \0
+        if l.startswith('msgid_plural'):
+            msgid += '\0'
+            l = l[12:]
+        # Now we are in a msgid section, output previous section
+        elif l.startswith('msgid'):
+            if section == STR:
+                add(msgid, msgstr, fuzzy)
+            section = ID
+            l = l[5:]
+            msgid = msgstr = ''
+        # Now we are in a msgstr section
+        elif l.startswith('msgstr'):
+            section = STR
+            l = l[6:]
+            # Check for plural forms
+            if l.startswith('['):
+                # Separate plural forms with \0
+                if not l.startswith('[0]'):
+                    msgstr += '\0'
+                # Ignore the index - must come in sequence
+                l = l[l.index(']') + 1:]
+        # Skip empty lines
+        l = l.strip()
+        if not l:
+            continue
+        # XXX: Does this always follow Python escape semantics?
+        l = eval(l)
+        if section == ID:
+            msgid += l
+        elif section == STR:
+            msgstr += l
+        else:
+            print >> sys.stderr, 'Syntax error on %s:%d' % (infile, lno), \
+                  'before:'
+            print >> sys.stderr, l
+            sys.exit(1)
+    # Add last entry
+    if section == STR:
+        add(msgid, msgstr, fuzzy)
+
+    # Compute output
+    output = generate()
+
+    try:
+        open(outfile,"wb").write(output)
+    except IOError,msg:
+        print >> sys.stderr, msg
+
+
+
+def main():
+    try:
+        opts, args = getopt.getopt(sys.argv[1:], 'hVo:',
+                                   ['help', 'version', 'output-file='])
+    except getopt.error, msg:
+        usage(1, msg)
+
+    outfile = None
+    # parse options
+    for opt, arg in opts:
+        if opt in ('-h', '--help'):
+            usage(0)
+        elif opt in ('-V', '--version'):
+            print >> sys.stderr, "msgfmt.py", __version__
+            sys.exit(0)
+        elif opt in ('-o', '--output-file'):
+            outfile = arg
+    # do it
+    if not args:
+        print >> sys.stderr, 'No input file given'
+        print >> sys.stderr, "Try `msgfmt --help' for more information."
+        return
+
+    for filename in args:
+        make(filename, outfile)
+
+
+if __name__ == '__main__':
+    main()
+
+# vim: set et sts=4 sw=4 :

Added: tracker/vendor/roundup/current/roundup/password.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/password.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,166 @@
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+#
+# $Id: password.py,v 1.15 2005/12/25 15:38:40 a1s Exp $
+
+"""Password handling (encoding, decoding).
+"""
+__docformat__ = 'restructuredtext'
+
+import sha, md5, re, string, random
+try:
+    import crypt
+except:
+    crypt = None
+    pass
+
+class PasswordValueError(ValueError):
+    ''' The password value is not valid '''
+    pass
+
+def encodePassword(plaintext, scheme, other=None):
+    '''Encrypt the plaintext password.
+    '''
+    if plaintext is None:
+        plaintext = ""
+    if scheme == 'SHA':
+        s = sha.sha(plaintext).hexdigest()
+    elif scheme == 'MD5':
+        s = md5.md5(plaintext).hexdigest()
+    elif scheme == 'crypt' and crypt is not None:
+        if other is not None:
+            salt = other
+        else:
+            saltchars = './0123456789'+string.letters
+            salt = random.choice(saltchars) + random.choice(saltchars)
+        s = crypt.crypt(plaintext, salt)
+    elif scheme == 'plaintext':
+        s = plaintext
+    else:
+        raise PasswordValueError, 'unknown encryption scheme %r'%scheme
+    return s
+
+def generatePassword(length=8):
+    chars = string.letters+string.digits
+    return ''.join([random.choice(chars) for x in range(length)])
+
+class Password:
+    '''The class encapsulates a Password property type value in the database.
+
+    The encoding of the password is one if None, 'SHA', 'MD5' or 'plaintext'.
+    The encodePassword function is used to actually encode the password from
+    plaintext. The None encoding is used in legacy databases where no
+    encoding scheme is identified.
+
+    The scheme is stored with the encoded data in the database:
+        {scheme}data
+
+    Example usage:
+    >>> p = Password('sekrit')
+    >>> p == 'sekrit'
+    1
+    >>> p != 'not sekrit'
+    1
+    >>> 'sekrit' == p
+    1
+    >>> 'not sekrit' != p
+    1
+    '''
+
+    default_scheme = 'SHA'        # new encryptions use this scheme
+    pwre = re.compile(r'{(\w+)}(.+)')
+
+    def __init__(self, plaintext=None, scheme=None, encrypted=None):
+        '''Call setPassword if plaintext is not None.'''
+        if scheme is None:
+            scheme = self.default_scheme
+        if plaintext is not None:
+            self.setPassword (plaintext, scheme)
+        elif encrypted is not None:
+            self.unpack(encrypted, scheme)
+        else:
+            self.scheme = self.default_scheme
+            self.password = None
+            self.plaintext = None
+
+    def unpack(self, encrypted, scheme=None):
+        '''Set the password info from the scheme:<encryted info> string
+           (the inverse of __str__)
+        '''
+        m = self.pwre.match(encrypted)
+        if m:
+            self.scheme = m.group(1)
+            self.password = m.group(2)
+            self.plaintext = None
+        else:
+            # currently plaintext - encrypt
+            self.setPassword(encrypted, scheme)
+
+    def setPassword(self, plaintext, scheme=None):
+        '''Sets encrypts plaintext.'''
+        if scheme is None:
+            scheme = self.default_scheme
+        self.scheme = scheme
+        self.password = encodePassword(plaintext, scheme)
+        self.plaintext = plaintext
+
+    def __cmp__(self, other):
+        '''Compare this password against another password.'''
+        # check to see if we're comparing instances
+        if isinstance(other, Password):
+            if self.scheme != other.scheme:
+                return cmp(self.scheme, other.scheme)
+            return cmp(self.password, other.password)
+
+        # assume password is plaintext
+        if self.password is None:
+            raise ValueError, 'Password not set'
+        return cmp(self.password, encodePassword(other, self.scheme,
+            self.password))
+
+    def __str__(self):
+        '''Stringify the encrypted password for database storage.'''
+        if self.password is None:
+            raise ValueError, 'Password not set'
+        return '{%s}%s'%(self.scheme, self.password)
+
+def test():
+    # SHA
+    p = Password('sekrit')
+    assert p == 'sekrit'
+    assert p != 'not sekrit'
+    assert 'sekrit' == p
+    assert 'not sekrit' != p
+
+    # MD5
+    p = Password('sekrit', 'MD5')
+    assert p == 'sekrit'
+    assert p != 'not sekrit'
+    assert 'sekrit' == p
+    assert 'not sekrit' != p
+
+    # crypt
+    p = Password('sekrit', 'crypt')
+    assert p == 'sekrit'
+    assert p != 'not sekrit'
+    assert 'sekrit' == p
+    assert 'not sekrit' != p
+
+if __name__ == '__main__':
+    test()
+
+# vim: set filetype=python sts=4 sw=4 et si :

Added: tracker/vendor/roundup/current/roundup/rfc2822.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/rfc2822.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,166 @@
+"""Some rfc822 functions taken from the new (python2.3) "email" module.
+"""
+__docformat__ = 'restructuredtext'
+
+import re
+from string import letters, digits
+from binascii import b2a_base64, a2b_base64
+
+ecre = re.compile(r'''
+  =\?                   # literal =?
+  (?P<charset>[^?]*?)   # non-greedy up to the next ? is the charset
+  \?                    # literal ?
+  (?P<encoding>[qb])    # either a "q" or a "b", case insensitive
+  \?                    # literal ?
+  (?P<encoded>.*?)      # non-greedy up to the next ?= is the encoded string
+  \?=                   # literal ?=
+  ''', re.VERBOSE | re.IGNORECASE)
+
+hqre = re.compile(r'^[A-z0-9!"#$%%&\'()*+,-./:;<=>?@\[\]^_`{|}~ ]+$')
+
+CRLF = '\r\n'
+
+def base64_decode(s, convert_eols=None):
+    """Decode a raw base64 string.
+
+    If convert_eols is set to a string value, all canonical email linefeeds,
+    e.g. "\\r\\n", in the decoded text will be converted to the value of
+    convert_eols.  os.linesep is a good choice for convert_eols if you are
+    decoding a text attachment.
+
+    This function does not parse a full MIME header value encoded with
+    base64 (like =?iso-8895-1?b?bmloISBuaWgh?=) -- please use the high
+    level email.Header class for that functionality.
+
+    Taken from 'email' module
+    """
+    if not s:
+        return s
+    
+    dec = a2b_base64(s)
+    if convert_eols:
+        return dec.replace(CRLF, convert_eols)
+    return dec
+
+def unquote_match(match):
+    """Turn a match in the form ``=AB`` to the ASCII character with value
+    0xab.
+
+    Taken from 'email' module
+    """
+    s = match.group(0)
+    return chr(int(s[1:3], 16))
+
+def qp_decode(s):
+    """Decode a string encoded with RFC 2045 MIME header 'Q' encoding.
+
+    This function does not parse a full MIME header value encoded with
+    quoted-printable (like =?iso-8895-1?q?Hello_World?=) -- please use
+    the high level email.Header class for that functionality.
+
+    Taken from 'email' module
+    """
+    s = s.replace('_', ' ')
+    return re.sub(r'=\w{2}', unquote_match, s)
+
+def _decode_header(header):
+    """Decode a message header value without converting charset.
+
+    Returns a list of (decoded_string, charset) pairs containing each of the
+    decoded parts of the header.  Charset is None for non-encoded parts of the
+    header, otherwise a lower-case string containing the name of the character
+    set specified in the encoded string.
+
+    Taken from 'email' module
+    """
+    # If no encoding, just return the header
+    header = str(header)
+    if not ecre.search(header):
+        return [(header, None)]
+
+    decoded = []
+    dec = ''
+    for line in header.splitlines():
+        # This line might not have an encoding in it
+        if not ecre.search(line):
+            decoded.append((line, None))
+            continue
+
+        parts = ecre.split(line)
+        while parts:
+            unenc = parts.pop(0)
+            if unenc:
+                if unenc.strip():
+                    decoded.append((unenc, None))
+            if parts:
+                charset, encoding = [s.lower() for s in parts[0:2]]
+                encoded = parts[2]
+                dec = ''
+                if encoding == 'q':
+                    dec = qp_decode(encoded)
+                elif encoding == 'b':
+                    dec = base64_decode(encoded)
+                else:
+                    dec = encoded
+
+                if decoded and decoded[-1][1] == charset:
+                    decoded[-1] = (decoded[-1][0] + dec, decoded[-1][1])
+                else:
+                    decoded.append((dec, charset))
+            del parts[0:3]
+    return decoded
+
+def decode_header(hdr):
+    """ Decodes rfc2822 encoded header and return utf-8 encoded string
+    """
+    if not hdr:
+        return None
+    outs = u""
+    for section in _decode_header(hdr):
+        charset = unaliasCharset(section[1])
+        outs += unicode(section[0], charset or 'iso-8859-1', 'replace')
+    return outs.encode('utf-8')
+
+def encode_header(header, charset='utf-8'):
+    """ Will encode in quoted-printable encoding only if header 
+    contains non latin characters
+    """
+
+    # Return empty headers unchanged
+    if not header:
+        return header
+
+    # return plain header if it is not contains non-ascii characters
+    if hqre.match(header):
+        return header
+    
+    quoted = ''
+    #max_encoded = 76 - len(charset) - 7
+    for c in header:
+        # Space may be represented as _ instead of =20 for readability
+        if c == ' ':
+            quoted += '_'
+        # These characters can be included verbatim
+        elif hqre.match(c) and c not in '_=':
+            quoted += c
+        # Otherwise, replace with hex value like =E2
+        else:
+            quoted += "=%02X" % ord(c)
+            plain = 0
+
+    return '=?%s?q?%s?=' % (charset, quoted)
+
+def unaliasCharset(charset):
+    if charset:
+        return charset.lower().replace("windows-", 'cp')
+        #return charset_table.get(charset.lower(), charset)
+    return None
+
+def test():
+    print encode_header("Contrary, Mary")
+    #print unaliasCharset('Windows-1251')
+
+if __name__ == '__main__':
+    test()
+
+# vim: et

Added: tracker/vendor/roundup/current/roundup/roundupdb.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/roundupdb.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,592 @@
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+#
+# $Id: roundupdb.py,v 1.123 2006/04/27 01:39:47 richard Exp $
+
+"""Extending hyperdb with types specific to issue-tracking.
+"""
+__docformat__ = 'restructuredtext'
+
+from __future__ import nested_scopes
+
+import re, os, smtplib, socket, time, random
+import cStringIO, base64, quopri, mimetypes
+
+from rfc2822 import encode_header
+
+from roundup import password, date, hyperdb
+from roundup.i18n import _
+
+# MessageSendError is imported for backwards compatibility
+from roundup.mailer import Mailer, straddr, MessageSendError
+
+class Database:
+
+    # remember the journal uid for the current journaltag so that:
+    # a. we don't have to look it up every time we need it, and
+    # b. if the journaltag disappears during a transaction, we don't barf
+    #    (eg. the current user edits their username)
+    journal_uid = None
+    def getuid(self):
+        """Return the id of the "user" node associated with the user
+        that owns this connection to the hyperdatabase."""
+        if self.journaltag is None:
+            return None
+        elif self.journaltag == 'admin':
+            # admin user may not exist, but always has ID 1
+            return '1'
+        else:
+            if (self.journal_uid is None or self.journal_uid[0] !=
+                    self.journaltag):
+                uid = self.user.lookup(self.journaltag)
+                self.journal_uid = (self.journaltag, uid)
+            return self.journal_uid[1]
+
+    def setCurrentUser(self, username):
+        """Set the user that is responsible for current database
+        activities.
+        """
+        self.journaltag = username
+
+    def isCurrentUser(self, username):
+        """Check if a given username equals the already active user.
+        """
+        return self.journaltag == username
+
+    def getUserTimezone(self):
+        """Return user timezone defined in 'timezone' property of user class.
+        If no such property exists return 0
+        """
+        userid = self.getuid()
+        try:
+            timezone = int(self.user.get(userid, 'timezone'))
+        except (KeyError, ValueError, TypeError):
+            # If there is no class 'user' or current user doesn't have timezone
+            # property or that property is not numeric assume he/she lives in
+            # Greenwich :)
+            timezone = getattr(self.config, 'DEFAULT_TIMEZONE', 0)
+        return timezone
+
+    def confirm_registration(self, otk):
+        props = self.getOTKManager().getall(otk)
+        for propname, proptype in self.user.getprops().items():
+            value = props.get(propname, None)
+            if value is None:
+                pass
+            elif isinstance(proptype, hyperdb.Date):
+                props[propname] = date.Date(value)
+            elif isinstance(proptype, hyperdb.Interval):
+                props[propname] = date.Interval(value)
+            elif isinstance(proptype, hyperdb.Password):
+                props[propname] = password.Password()
+                props[propname].unpack(value)
+
+        # tag new user creation with 'admin'
+        self.journaltag = 'admin'
+
+        # create the new user
+        cl = self.user
+
+        props['roles'] = self.config.NEW_WEB_USER_ROLES
+        userid = cl.create(**props)
+        # clear the props from the otk database
+        self.getOTKManager().destroy(otk)
+        self.commit()
+
+        return userid
+
+
+class DetectorError(RuntimeError):
+    """ Raised by detectors that want to indicate that something's amiss
+    """
+    pass
+
+# deviation from spec - was called IssueClass
+class IssueClass:
+    """This class is intended to be mixed-in with a hyperdb backend
+    implementation. The backend should provide a mechanism that
+    enforces the title, messages, files, nosy and superseder
+    properties:
+
+    - title = hyperdb.String(indexme='yes')
+    - messages = hyperdb.Multilink("msg")
+    - files = hyperdb.Multilink("file")
+    - nosy = hyperdb.Multilink("user")
+    - superseder = hyperdb.Multilink(classname)
+    """
+
+    # The tuple below does not affect the class definition.
+    # It just lists all names of all issue properties
+    # marked for message extraction tool.
+    #
+    # XXX is there better way to get property names into message catalog??
+    #
+    # Note that this list also includes properties
+    # defined in the classic template:
+    # assignedto, topic, priority, status.
+    (
+        ''"title", ''"messages", ''"files", ''"nosy", ''"superseder",
+        ''"assignedto", ''"topic", ''"priority", ''"status",
+        # following properties are common for all hyperdb classes
+        # they are listed here to keep things in one place
+        ''"actor", ''"activity", ''"creator", ''"creation",
+    )
+
+    # New methods:
+    def addmessage(self, nodeid, summary, text):
+        """Add a message to an issue's mail spool.
+
+        A new "msg" node is constructed using the current date, the user that
+        owns the database connection as the author, and the specified summary
+        text.
+
+        The "files" and "recipients" fields are left empty.
+
+        The given text is saved as the body of the message and the node is
+        appended to the "messages" field of the specified issue.
+        """
+
+    def nosymessage(self, nodeid, msgid, oldvalues, whichnosy='nosy',
+            from_address=None, cc=[], bcc=[]):
+        """Send a message to the members of an issue's nosy list.
+
+        The message is sent only to users on the nosy list who are not
+        already on the "recipients" list for the message.
+
+        These users are then added to the message's "recipients" list.
+
+        If 'msgid' is None, the message gets sent only to the nosy
+        list, and it's called a 'System Message'.
+
+        The "cc" argument indicates additional recipients to send the
+        message to that may not be specified in the message's recipients
+        list.
+
+        The "bcc" argument also indicates additional recipients to send the
+        message to that may not be specified in the message's recipients
+        list. These recipients will not be included in the To: or Cc:
+        address lists.
+        """
+        if msgid:
+            authid = self.db.msg.get(msgid, 'author')
+            recipients = self.db.msg.get(msgid, 'recipients', [])
+        else:
+            # "system message"
+            authid = None
+            recipients = []
+
+        sendto = []
+        bcc_sendto = []
+        seen_message = {}
+        for recipient in recipients:
+            seen_message[recipient] = 1
+
+        def add_recipient(userid, to):
+            # make sure they have an address
+            address = self.db.user.get(userid, 'address')
+            if address:
+                to.append(address)
+                recipients.append(userid)
+
+        def good_recipient(userid):
+            # Make sure we don't send mail to either the anonymous
+            # user or a user who has already seen the message.
+            return (userid and
+                    (self.db.user.get(userid, 'username') != 'anonymous') and
+                    not seen_message.has_key(userid))
+
+        # possibly send the message to the author, as long as they aren't
+        # anonymous
+        if (good_recipient(authid) and
+            (self.db.config.MESSAGES_TO_AUTHOR == 'yes' or
+             (self.db.config.MESSAGES_TO_AUTHOR == 'new' and not oldvalues))):
+            add_recipient(authid, sendto)
+
+        if authid:
+            seen_message[authid] = 1
+
+        # now deal with the nosy and cc people who weren't recipients.
+        for userid in cc + self.get(nodeid, whichnosy):
+            if good_recipient(userid):
+                add_recipient(userid, sendto)
+
+        # now deal with bcc people.
+        for userid in bcc:
+            if good_recipient(userid):
+                add_recipient(userid, bcc_sendto)
+
+        if oldvalues:
+            note = self.generateChangeNote(nodeid, oldvalues)
+        else:
+            note = self.generateCreateNote(nodeid)
+
+        # If we have new recipients, update the message's recipients
+        # and send the mail.
+        if sendto or bcc_sendto:
+            if msgid is not None:
+                self.db.msg.set(msgid, recipients=recipients)
+            self.send_message(nodeid, msgid, note, sendto, from_address,
+                bcc_sendto)
+
+    # backwards compatibility - don't remove
+    sendmessage = nosymessage
+
+    def send_message(self, nodeid, msgid, note, sendto, from_address=None,
+            bcc_sendto=[]):
+        '''Actually send the nominated message from this node to the sendto
+           recipients, with the note appended.
+        '''
+        users = self.db.user
+        messages = self.db.msg
+        files = self.db.file
+
+        if msgid is None:
+            inreplyto = None
+            messageid = None
+        else:
+            inreplyto = messages.get(msgid, 'inreplyto')
+            messageid = messages.get(msgid, 'messageid')
+
+        # make up a messageid if there isn't one (web edit)
+        if not messageid:
+            # this is an old message that didn't get a messageid, so
+            # create one
+            messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
+                                           self.classname, nodeid,
+                                           self.db.config.MAIL_DOMAIN)
+            if msgid is not None:
+                messages.set(msgid, messageid=messageid)
+
+        # compose title
+        cn = self.classname
+        title = self.get(nodeid, 'title') or '%s message copy'%cn
+
+        # figure author information
+        if msgid:
+            authid = messages.get(msgid, 'author')
+            authname = users.get(authid, 'realname')
+            if not authname:
+                authname = users.get(authid, 'username', '')
+            authaddr = users.get(authid, 'address', '')
+        else:
+            # "system message"
+            authid = None
+            authname = 'admin'
+            authaddr = self.db.config.ADMIN_EMAIL
+
+        if authaddr:
+            authaddr = " <%s>" % straddr( ('',authaddr) )
+
+        # make the message body
+        m = ['']
+
+        # put in roundup's signature
+        if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
+            m.append(self.email_signature(nodeid, msgid))
+
+        # add author information
+        if authid:
+            if len(self.get(nodeid,'messages')) == 1:
+                m.append(_("New submission from %(authname)s%(authaddr)s:")
+                    % locals())
+            else:
+                m.append(_("%(authname)s%(authaddr)s added the comment:")
+                    % locals())
+        else:
+            m.append(_("System message:"))
+        m.append('')
+
+        # add the content
+        if msgid is not None:
+            m.append(messages.get(msgid, 'content', ''))
+
+        # add the change note
+        if note:
+            m.append(note)
+
+        # put in roundup's signature
+        if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
+            m.append(self.email_signature(nodeid, msgid))
+
+        # encode the content as quoted-printable
+        charset = getattr(self.db.config, 'EMAIL_CHARSET', 'utf-8')
+        m = '\n'.join(m)
+        if charset != 'utf-8':
+            m = unicode(m, 'utf-8').encode(charset)
+        content = cStringIO.StringIO(m)
+        content_encoded = cStringIO.StringIO()
+        quopri.encode(content, content_encoded, 0)
+        content_encoded = content_encoded.getvalue()
+
+        # get the files for this message
+        if msgid is None:
+            message_files = None
+        else:
+            message_files = messages.get(msgid, 'files')
+
+        # make sure the To line is always the same (for testing mostly)
+        sendto.sort()
+
+        # make sure we have a from address
+        if from_address is None:
+            from_address = self.db.config.TRACKER_EMAIL
+
+        # additional bit for after the From: "name"
+        from_tag = getattr(self.db.config, 'EMAIL_FROM_TAG', '')
+        if from_tag:
+            from_tag = ' ' + from_tag
+
+        subject = '[%s%s] %s'%(cn, nodeid, title)
+        author = (authname + from_tag, from_address)
+
+        # send an individual message per recipient?
+        if self.db.config.NOSY_EMAIL_SENDING != 'single':
+            sendto = [[address] for address in sendto]
+        else:
+            sendto = [sendto]
+
+        # now send one or more messages
+        # TODO: I believe we have to create a new message each time as we
+        # can't fiddle the recipients in the message ... worth testing
+        # and/or fixing some day
+        first = True
+        for sendto in sendto:
+            # create the message
+            mailer = Mailer(self.db.config)
+            message, writer = mailer.get_standard_message(sendto, subject,
+                author)
+
+            # set reply-to to the tracker
+            tracker_name = self.db.config.TRACKER_NAME
+            if charset != 'utf-8':
+                tracker = unicode(tracker_name, 'utf-8').encode(charset)
+            tracker_name = encode_header(tracker_name, charset)
+            writer.addheader('Reply-To', straddr((tracker_name, from_address)))
+
+            # message ids
+            if messageid:
+                writer.addheader('Message-Id', messageid)
+            if inreplyto:
+                writer.addheader('In-Reply-To', inreplyto)
+
+            # attach files
+            if message_files:
+                part = writer.startmultipartbody('mixed')
+                part = writer.nextpart()
+                part.addheader('Content-Transfer-Encoding', 'quoted-printable')
+                body = part.startbody('text/plain; charset=%s'%charset)
+                body.write(content_encoded)
+                for fileid in message_files:
+                    name = files.get(fileid, 'name')
+                    mime_type = files.get(fileid, 'type')
+                    content = files.get(fileid, 'content')
+                    part = writer.nextpart()
+                    if mime_type == 'text/plain':
+                        part.addheader('Content-Disposition',
+                            'attachment;\n filename="%s"'%name)
+                        try:
+                            content.decode('ascii')
+                        except UnicodeError:
+                            # the content cannot be 7bit-encoded.
+                            # use quoted printable
+                            part.addheader('Content-Transfer-Encoding',
+                                'quoted-printable')
+                            body = part.startbody('text/plain')
+                            body.write(quopri.encodestring(content))
+                        else:
+                            part.addheader('Content-Transfer-Encoding', '7bit')
+                            body = part.startbody('text/plain')
+                            body.write(content)
+                    else:
+                        # some other type, so encode it
+                        if not mime_type:
+                            # this should have been done when the file was saved
+                            mime_type = mimetypes.guess_type(name)[0]
+                        if mime_type is None:
+                            mime_type = 'application/octet-stream'
+                        part.addheader('Content-Disposition',
+                            'attachment;\n filename="%s"'%name)
+                        part.addheader('Content-Transfer-Encoding', 'base64')
+                        body = part.startbody(mime_type)
+                        body.write(base64.encodestring(content))
+                writer.lastpart()
+            else:
+                writer.addheader('Content-Transfer-Encoding',
+                    'quoted-printable')
+                body = writer.startbody('text/plain; charset=%s'%charset)
+                body.write(content_encoded)
+
+            if first:
+                mailer.smtp_send(sendto + bcc_sendto, message)
+            else:
+                mailer.smtp_send(sendto, message)
+            first = False
+
+    def email_signature(self, nodeid, msgid):
+        ''' Add a signature to the e-mail with some useful information
+        '''
+        # simplistic check to see if the url is valid,
+        # then append a trailing slash if it is missing
+        base = self.db.config.TRACKER_WEB
+        if (not isinstance(base , type('')) or
+            not (base.startswith('http://') or base.startswith('https://'))):
+            web = "Configuration Error: TRACKER_WEB isn't a " \
+                "fully-qualified URL"
+        else:
+            if not base.endswith('/'):
+                base = base + '/'
+            web = base + self.classname + nodeid
+
+        # ensure the email address is properly quoted
+        email = straddr((self.db.config.TRACKER_NAME,
+            self.db.config.TRACKER_EMAIL))
+
+        line = '_' * max(len(web)+2, len(email))
+        return '\n%s\n%s\n<%s>\n%s'%(line, email, web, line)
+
+
+    def generateCreateNote(self, nodeid):
+        """Generate a create note that lists initial property values
+        """
+        cn = self.classname
+        cl = self.db.classes[cn]
+        props = cl.getprops(protected=0)
+
+        # list the values
+        m = []
+        prop_items = props.items()
+        prop_items.sort()
+        for propname, prop in prop_items:
+            value = cl.get(nodeid, propname, None)
+            # skip boring entries
+            if not value:
+                continue
+            if isinstance(prop, hyperdb.Link):
+                link = self.db.classes[prop.classname]
+                if value:
+                    key = link.labelprop(default_to_id=1)
+                    if key:
+                        value = link.get(value, key)
+                else:
+                    value = ''
+            elif isinstance(prop, hyperdb.Multilink):
+                if value is None: value = []
+                l = []
+                link = self.db.classes[prop.classname]
+                key = link.labelprop(default_to_id=1)
+                if key:
+                    value = [link.get(entry, key) for entry in value]
+                value.sort()
+                value = ', '.join(value)
+            m.append('%s: %s'%(propname, value))
+        m.insert(0, '----------')
+        m.insert(0, '')
+        return '\n'.join(m)
+
+    def generateChangeNote(self, nodeid, oldvalues):
+        """Generate a change note that lists property changes
+        """
+        if not isinstance(oldvalues, type({})):
+            raise TypeError("'oldvalues' must be dict-like, not %s."%
+                type(oldvalues))
+
+        cn = self.classname
+        cl = self.db.classes[cn]
+        changed = {}
+        props = cl.getprops(protected=0)
+
+        # determine what changed
+        for key in oldvalues.keys():
+            if key in ['files','messages']:
+                continue
+            if key in ('actor', 'activity', 'creator', 'creation'):
+                continue
+            # not all keys from oldvalues might be available in database
+            # this happens when property was deleted
+            try:
+                new_value = cl.get(nodeid, key)
+            except KeyError:
+                continue
+            # the old value might be non existent
+            # this happens when property was added
+            try:
+                old_value = oldvalues[key]
+                if type(new_value) is type([]):
+                    new_value.sort()
+                    old_value.sort()
+                if new_value != old_value:
+                    changed[key] = old_value
+            except:
+                changed[key] = new_value
+
+        # list the changes
+        m = []
+        changed_items = changed.items()
+        changed_items.sort()
+        for propname, oldvalue in changed_items:
+            prop = props[propname]
+            value = cl.get(nodeid, propname, None)
+            if isinstance(prop, hyperdb.Link):
+                link = self.db.classes[prop.classname]
+                key = link.labelprop(default_to_id=1)
+                if key:
+                    if value:
+                        value = link.get(value, key)
+                    else:
+                        value = ''
+                    if oldvalue:
+                        oldvalue = link.get(oldvalue, key)
+                    else:
+                        oldvalue = ''
+                change = '%s -> %s'%(oldvalue, value)
+            elif isinstance(prop, hyperdb.Multilink):
+                change = ''
+                if value is None: value = []
+                if oldvalue is None: oldvalue = []
+                l = []
+                link = self.db.classes[prop.classname]
+                key = link.labelprop(default_to_id=1)
+                # check for additions
+                for entry in value:
+                    if entry in oldvalue: continue
+                    if key:
+                        l.append(link.get(entry, key))
+                    else:
+                        l.append(entry)
+                if l:
+                    l.sort()
+                    change = '+%s'%(', '.join(l))
+                    l = []
+                # check for removals
+                for entry in oldvalue:
+                    if entry in value: continue
+                    if key:
+                        l.append(link.get(entry, key))
+                    else:
+                        l.append(entry)
+                if l:
+                    l.sort()
+                    change += ' -%s'%(', '.join(l))
+            else:
+                change = '%s -> %s'%(oldvalue, value)
+            m.append('%s: %s'%(propname, change))
+        if m:
+            m.insert(0, '----------')
+            m.insert(0, '')
+        return '\n'.join(m)
+
+# vim: set filetype=python sts=4 sw=4 et si :

Added: tracker/vendor/roundup/current/roundup/scripts/.cvsignore
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/scripts/.cvsignore	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,3 @@
+*.pyc
+*.pyo
+*.cover

Added: tracker/vendor/roundup/current/roundup/scripts/__init__.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/scripts/__init__.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,26 @@
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+# 
+# $Id: __init__.py,v 1.3 2004/02/11 23:55:10 richard Exp $
+
+'''Subpackage containing the modules that implement the command
+line tools.
+
+Note that these are imported by script stubs generated by "setup.py".
+'''
+__docformat__ = 'restructuredtext'
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/roundup/scripts/roundup_admin.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/scripts/roundup_admin.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,43 @@
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+# 
+# $Id: roundup_admin.py,v 1.6 2004/02/11 23:55:10 richard Exp $
+
+"""Command-line script stub that calls the roundup.admin functions.
+"""
+__docformat__ = 'restructuredtext'
+
+# python version check
+from roundup import version_check
+
+# import the admin tool guts and make it go
+from roundup.admin import AdminTool
+from roundup.i18n import _
+
+import sys
+
+def run():
+    # time out after a minute if we can
+    import socket
+    if hasattr(socket, 'setdefaulttimeout'):
+        socket.setdefaulttimeout(60)
+    tool = AdminTool()
+    sys.exit(tool.main())
+
+if __name__ == '__main__':
+    run()
+
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/roundup/scripts/roundup_demo.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/scripts/roundup_demo.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,44 @@
+#! /usr/bin/env python
+#
+# Copyright 2004 Richard Jones (richard at mechanicalcat.net)
+#
+# $Id: roundup_demo.py,v 1.1 2004/10/18 07:56:09 a1s Exp $
+
+import sys
+
+from roundup import admin, configuration, demo, instance
+from roundup.i18n import _
+
+DEFAULT_HOME = './demo'
+
+def run():
+    home = DEFAULT_HOME
+    nuke = sys.argv[-1] == 'nuke'
+    # if there is no tracker in home, force nuke
+    try:
+        instance.open(home)
+    except configuration.NoConfigError:
+        nuke = 1
+    # if we are to create the tracker, prompt for home
+    if nuke:
+        if len(sys.argv) > 2:
+            backend = sys.argv[-2]
+        else:
+            backend = 'anydbm'
+        # FIXME: i'd like to have an option to abort the tracker creation
+        #   say, by entering a single dot.  but i cannot think of
+        #   appropriate prompt for that.
+        home = raw_input(
+            _('Enter directory path to create demo tracker [%s]: ') % home)
+        if not home:
+            home = DEFAULT_HOME
+        # install
+        demo.install_demo(home, backend,
+            admin.AdminTool().listTemplates()['classic']['path'])
+    # run
+    demo.run_demo(home)
+
+if __name__ == '__main__':
+    run()
+
+# vim: set et sts=4 sw=4 :

Added: tracker/vendor/roundup/current/roundup/scripts/roundup_gettext.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/scripts/roundup_gettext.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,54 @@
+#! /usr/bin/env python
+#
+# Copyright 2004 Richard Jones (richard at mechanicalcat.net)
+#
+# $Id: roundup_gettext.py,v 1.1 2004/10/20 10:25:23 a1s Exp $
+
+"""Extract translatable strings from tracker templates"""
+
+import os
+import sys
+
+from roundup.i18n import _
+from roundup.cgi.TAL import talgettext
+
+# name of message template file.
+# i don't think this will ever need to be changed, but still...
+TEMPLATE_FILE = "messages.pot"
+
+def run():
+    # return unless command line arguments contain single directory path
+    if (len(sys.argv) != 2) or (sys.argv[1] in ("-h", "--help")):
+        print _("Usage: %(program)s <tracker home>") % {"program": sys.argv[0]}
+        return
+    # collect file paths of html templates
+    home = os.path.abspath(sys.argv[1])
+    htmldir = os.path.join(home, "html")
+    if os.path.isdir(htmldir):
+        # glob is not used because i want to match file names
+        # without case sensitivity, and that is easier done this way.
+        htmlfiles = [filename for filename in os.listdir(htmldir)
+            if os.path.isfile(os.path.join(htmldir, filename))
+            and filename.lower().endswith(".html")]
+    else:
+        htmlfiles = []
+    # return if no html files found
+    if not htmlfiles:
+        print _("No tracker templates found in directory %s") % home
+        return
+    # change to locale dir to have relative source references
+    locale = os.path.join(home, "locale")
+    if not os.path.isdir(locale):
+        os.mkdir(locale)
+    os.chdir(locale)
+    # tweak sys.argv as this is the only way to tell talgettext what to do
+    # Note: unix-style paths used instead of os.path.join deliberately
+    sys.argv[1:] = ["-o", TEMPLATE_FILE] \
+        + ["../html/" + filename for filename in htmlfiles]
+    # run
+    talgettext.main()
+
+if __name__ == "__main__":
+    run()
+
+# vim: set et sts=4 sw=4 :

Added: tracker/vendor/roundup/current/roundup/scripts/roundup_mailgw.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/scripts/roundup_mailgw.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,197 @@
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+#
+# $Id: roundup_mailgw.py,v 1.21 2005/07/18 01:19:57 richard Exp $
+
+"""Command-line script stub that calls the roundup.mailgw.
+"""
+__docformat__ = 'restructuredtext'
+
+# python version check
+from roundup import version_check
+from roundup import __version__ as roundup_version
+
+import sys, os, re, cStringIO, getopt, socket
+
+from roundup import mailgw
+from roundup.i18n import _
+
+def usage(args, message=None):
+    if message is not None:
+        print message
+    print _(
+"""Usage: %(program)s [-v] [-c] [[-C class] -S field=value]* <instance home> [method]
+
+Options:
+ -v: print version and exit
+ -c: default class of item to create (else the tracker's MAIL_DEFAULT_CLASS)
+ -C / -S: see below
+
+The roundup mail gateway may be called in one of four ways:
+ . with an instance home as the only argument,
+ . with both an instance home and a mail spool file,
+ . with both an instance home and a POP/APOP server account, or
+ . with both an instance home and a IMAP/IMAPS server account.
+
+It also supports optional -C and -S arguments that allows you to set a
+fields for a class created by the roundup-mailgw. The default class if
+not specified is msg, but the other classes: issue, file, user can
+also be used. The -S or --set options uses the same
+property=value[;property=value] notation accepted by the command line
+roundup command or the commands that can be given on the Subject line
+of an email message.
+
+It can let you set the type of the message on a per email address basis.
+
+PIPE:
+ In the first case, the mail gateway reads a single message from the
+ standard input and submits the message to the roundup.mailgw module.
+
+UNIX mailbox:
+ In the second case, the gateway reads all messages from the mail spool
+ file and submits each in turn to the roundup.mailgw module. The file is
+ emptied once all messages have been successfully handled. The file is
+ specified as:
+   mailbox /path/to/mailbox
+
+POP:
+ In the third case, the gateway reads all messages from the POP server
+ specified and submits each in turn to the roundup.mailgw module. The
+ server is specified as:
+    pop username:password at server
+ The username and password may be omitted:
+    pop username at server
+    pop server
+ are both valid. The username and/or password will be prompted for if
+ not supplied on the command-line.
+
+APOP:
+ Same as POP, but using Authenticated POP:
+    apop username:password at server
+
+IMAP:
+ Connect to an IMAP server. This supports the same notation as that of
+ POP mail.
+    imap username:password at server
+ It also allows you to specify a specific mailbox other than INBOX using
+ this format:
+    imap username:password at server mailbox
+
+IMAPS:
+ Connect to an IMAP server over ssl.
+ This supports the same notation as IMAP.
+    imaps username:password at server [mailbox]
+
+""")%{'program': args[0]}
+    return 1
+
+def main(argv):
+    '''Handle the arguments to the program and initialise environment.
+    '''
+    # take the argv array and parse it leaving the non-option
+    # arguments in the args array.
+    try:
+        optionsList, args = getopt.getopt(argv[1:], 'vcC:S:', ['set=',
+            'class='])
+    except getopt.GetoptError:
+        # print help information and exit:
+        usage(argv)
+        sys.exit(2)
+
+    for (opt, arg) in optionsList:
+        if opt == '-v':
+            print '%s (python %s)'%(roundup_version, sys.version.split()[0])
+            return
+
+    # figure the instance home
+    if len(args) > 0:
+        instance_home = args[0]
+    else:
+        instance_home = os.environ.get('ROUNDUP_INSTANCE', '')
+    if not (instance_home and os.path.isdir(instance_home)):
+        return usage(argv)
+
+    # get the instance
+    import roundup.instance
+    instance = roundup.instance.open(instance_home)
+
+    # get a mail handler
+    db = instance.open('admin')
+
+    # now wrap in try/finally so we always close the database
+    try:
+        if hasattr(instance, 'MailGW'):
+            handler = instance.MailGW(instance, db, optionsList)
+        else:
+            handler = mailgw.MailGW(instance, db, optionsList)
+
+        # if there's no more arguments, read a single message from stdin
+        if len(args) == 1:
+            return handler.do_pipe()
+
+        # otherwise, figure what sort of mail source to handle
+        if len(args) < 3:
+            return usage(argv, _('Error: not enough source specification information'))
+        source, specification = args[1:3]
+
+        # time out net connections after a minute if we can
+        if source not in ('mailbox', 'imaps'):
+            if hasattr(socket, 'setdefaulttimeout'):
+                socket.setdefaulttimeout(60)
+
+        if source == 'mailbox':
+            return handler.do_mailbox(specification)
+        elif source == 'pop':
+            m = re.match(r'((?P<user>[^:]+)(:(?P<pass>.+))?@)?(?P<server>.+)',
+                specification)
+            if m:
+                return handler.do_pop(m.group('server'), m.group('user'),
+                    m.group('pass'))
+            return usage(argv, _('Error: pop specification not valid'))
+        elif source == 'apop':
+            m = re.match(r'((?P<user>[^:]+)(:(?P<pass>.+))?@)?(?P<server>.+)',
+                specification)
+            if m:
+                return handler.do_apop(m.group('server'), m.group('user'),
+                    m.group('pass'))
+            return usage(argv, _('Error: apop specification not valid'))
+        elif source == 'imap' or source == 'imaps':
+            m = re.match(r'((?P<user>[^:]+)(:(?P<pass>.+))?@)?(?P<server>.+)',
+                specification)
+            if m:
+                ssl = 0
+                if source == 'imaps':
+                    ssl = 1
+                mailbox = ''
+                if len(args) > 3:
+                    mailbox = args[3]
+                return handler.do_imap(m.group('server'), m.group('user'),
+                    m.group('pass'), mailbox, ssl)
+
+        return usage(argv, _('Error: The source must be either "mailbox",'
+            ' "pop", "apop", "imap" or "imaps"'))
+    finally:
+        # handler might have closed the initial db and opened a new one
+        handler.db.close()
+
+def run():
+    sys.exit(main(sys.argv))
+
+# call main
+if __name__ == '__main__':
+    run()
+
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/roundup/scripts/roundup_server.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/scripts/roundup_server.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,778 @@
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+#
+
+"""Command-line script that runs a server over roundup.cgi.client.
+
+$Id: roundup_server.py,v 1.83 2006/04/27 04:59:37 richard Exp $
+"""
+__docformat__ = 'restructuredtext'
+
+import errno, cgi, getopt, os, socket, sys, traceback, urllib, time
+import ConfigParser, BaseHTTPServer, SocketServer, StringIO
+
+# python version check
+from roundup import configuration, version_check
+from roundup import __version__ as roundup_version
+
+# Roundup modules of use here
+from roundup.cgi import cgitb, client
+import roundup.instance
+from roundup.i18n import _
+
+# "default" favicon.ico
+# generate by using "icotool" and tools/base64
+import zlib, base64
+favico = zlib.decompress(base64.decodestring('''
+eJztjr1PmlEUh59XgVoshdYPWorFIhaRFq0t9pNq37b60lYSTRzcTFw6GAfj5gDYaF0dTB0MxMSE
+gQQd3FzKJiEC0UCIUUN1M41pV2JCXySg/0ITn5tfzvmdc+85FwT56HSc81UJjXJsk1UsNcsSqCk1
+BS64lK+vr7OyssLJyQl2ux2j0cjU1BQajYZIJEIwGMRms+H3+zEYDExOTjI2Nsbm5iZWqxWv18vW
+1hZDQ0Ok02kmJiY4Ojpienqa3d1dxsfHUSqVeDwe5ufnyeVyrK6u4nK5ODs7Y3FxEYfDwdzcHCaT
+icPDQ5LJJIIgMDIyQj6fZ39/n+3tbdbW1pAkiYWFBWZmZtjb2yMejzM8PEwgEMDn85HNZonFYqjV
+asLhMMvLy2QyGfR6PaOjowwODmKxWDg+PkalUhEKhSgUCiwtLWE2m9nZ2UGhULCxscHp6SmpVIpo
+NMrs7CwHBwdotVoSiQRXXPG/IzY7RHtt922xjFRb01H1XhKfPBNbi/7my7rrLXJ88eppvxwEfV3f
+NY3Y6exofVdsV3+2wnPFDdPjB83n7xuVpcFvygPbGwxF31LZIKrQDfR2Xvh7lmrX654L/7bvlnng
+bn3Zuj8M9Hepux6VfZtW1yA6K7cfGqVu8TL325u+fHTb71QKbk+7TZQ+lTc6RcnpqW8qmVQBoj/g
+23eo0sr/NIGvB37K+lOWXMvJ+uWFeKGU/03Cb7n3D4M3wxI=
+'''.strip()))
+
+DEFAULT_PORT = 8080
+
+# See what types of multiprocess server are available
+# Note: the order is important.  Preferred multiprocess type
+#   is the last element of this list.
+# "debug" means "none" + no tracker/template cache
+MULTIPROCESS_TYPES = ["debug", "none"]
+try:
+    import thread
+except ImportError:
+    pass
+else:
+    MULTIPROCESS_TYPES.append("thread")
+if hasattr(os, 'fork'):
+    MULTIPROCESS_TYPES.append("fork")
+DEFAULT_MULTIPROCESS = MULTIPROCESS_TYPES[-1]
+
+class RoundupRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
+    TRACKER_HOMES = {}
+    TRACKERS = None
+    LOG_IPADDRESS = 1
+    DEBUG_MODE = False
+    CONFIG = None
+
+    def get_tracker(self, name):
+        """Return a tracker instance for given tracker name"""
+        # Note: try/except KeyError works faster that has_key() check
+        #   if the key is usually found in the dictionary
+        #
+        # Return cached tracker instance if we have a tracker cache
+        if self.TRACKERS:
+            try:
+                return self.TRACKERS[name]
+            except KeyError:
+                pass
+        # No cached tracker.  Look for home path.
+        try:
+            tracker_home = self.TRACKER_HOMES[name]
+        except KeyError:
+            raise client.NotFound
+        # open the instance
+        tracker = roundup.instance.open(tracker_home)
+        # and cache it if we have a tracker cache
+        if self.TRACKERS:
+            self.TRACKERS[name] = tracker
+        return tracker
+
+    def run_cgi(self):
+        """ Execute the CGI command. Wrap an innner call in an error
+            handler so all errors can be caught.
+        """
+        save_stdin = sys.stdin
+        sys.stdin = self.rfile
+        try:
+            self.inner_run_cgi()
+        except client.NotFound:
+            self.send_error(404, self.path)
+        except client.Unauthorised, message:
+            self.send_error(403, '%s (%s)'%(self.path, message))
+        except:
+            exc, val, tb = sys.exc_info()
+            if hasattr(socket, 'timeout') and isinstance(val, socket.timeout):
+                self.log_error('timeout')
+            else:
+                # it'd be nice to be able to detect if these are going to have
+                # any effect...
+                self.send_response(400)
+                self.send_header('Content-Type', 'text/html')
+                self.end_headers()
+                if self.DEBUG_MODE:
+                    try:
+                        reload(cgitb)
+                        self.wfile.write(cgitb.breaker())
+                        self.wfile.write(cgitb.html())
+                    except:
+                        s = StringIO.StringIO()
+                        traceback.print_exc(None, s)
+                        self.wfile.write("<pre>")
+                        self.wfile.write(cgi.escape(s.getvalue()))
+                        self.wfile.write("</pre>\n")
+                else:
+                    # user feedback
+                    self.wfile.write(cgitb.breaker())
+                    ts = time.ctime()
+                    self.wfile.write('''<p>%s: An error occurred. Please check
+                    the server log for more infomation.</p>'''%ts)
+                    # out to the logfile
+                    print 'EXCEPTION AT', ts
+                    traceback.print_exc()
+        sys.stdin = save_stdin
+
+    do_GET = do_POST = do_HEAD = run_cgi
+
+    def index(self):
+        ''' Print up an index of the available trackers
+        '''
+        keys = self.TRACKER_HOMES.keys()
+        if len(keys) == 1:
+            self.send_response(302)
+            self.send_header('Location', urllib.quote(keys[0]) + '/index')
+        else:
+            self.send_response(200)
+        self.send_header('Content-Type', 'text/html')
+        self.end_headers()
+        w = self.wfile.write
+        w(_('<html><head><title>Roundup trackers index</title></head>\n'
+            '<body><h1>Roundup trackers index</h1><ol>\n'))
+        keys.sort()
+        for tracker in keys:
+            w('<li><a href="%(tracker_url)s/index">%(tracker_name)s</a>\n'%{
+                'tracker_url': urllib.quote(tracker),
+                'tracker_name': cgi.escape(tracker)})
+        w('</ol></body></html>')
+
+    def inner_run_cgi(self):
+        ''' This is the inner part of the CGI handling
+        '''
+        rest = self.path
+
+        # file-like object for the favicon.ico file information
+        favicon_fileobj = None
+
+        if rest == '/favicon.ico':
+            # check to see if a custom favicon was specified, and set
+            # favicon_fileobj to the input file
+            if self.CONFIG is not None:
+                favicon_filepath = os.path.abspath(self.CONFIG['FAVICON'])
+
+                if os.access(favicon_filepath, os.R_OK):
+                    favicon_fileobj = open(favicon_filepath, 'rb')
+
+
+            if favicon_fileobj is None:
+                favicon_fileobj = StringIO.StringIO(favico)
+
+            self.send_response(200)
+            self.send_header('Content-Type', 'image/x-icon')
+            self.end_headers()
+
+            # this bufsize is completely arbitrary, I picked 4K because it sounded good.
+            # if someone knows of a better buffer size, feel free to plug it in.
+            bufsize = 4 * 1024
+            Processing = True
+            while Processing:
+                data = favicon_fileobj.read(bufsize)
+                if len(data) > 0:
+                    self.wfile.write(data)
+                else:
+                    Processing = False
+
+            favicon_fileobj.close()
+
+            return
+
+        i = rest.rfind('?')
+        if i >= 0:
+            rest, query = rest[:i], rest[i+1:]
+        else:
+            query = ''
+
+        # no tracker - spit out the index
+        if rest == '/':
+            self.index()
+            return
+
+        # figure the tracker
+        l_path = rest.split('/')
+        tracker_name = urllib.unquote(l_path[1])
+
+        # handle missing trailing '/'
+        if len(l_path) == 2:
+            self.send_response(301)
+            # redirect - XXX https??
+            protocol = 'http'
+            url = '%s://%s%s/'%(protocol, self.headers['host'], self.path)
+            self.send_header('Location', url)
+            self.end_headers()
+            self.wfile.write('Moved Permanently')
+            return
+
+        # figure out what the rest of the path is
+        if len(l_path) > 2:
+            rest = '/'.join(l_path[2:])
+        else:
+            rest = '/'
+
+        # Set up the CGI environment
+        env = {}
+        env['TRACKER_NAME'] = tracker_name
+        env['REQUEST_METHOD'] = self.command
+        env['PATH_INFO'] = urllib.unquote(rest)
+        if query:
+            env['QUERY_STRING'] = query
+        if self.headers.typeheader is None:
+            env['CONTENT_TYPE'] = self.headers.type
+        else:
+            env['CONTENT_TYPE'] = self.headers.typeheader
+        length = self.headers.getheader('content-length')
+        if length:
+            env['CONTENT_LENGTH'] = length
+        co = filter(None, self.headers.getheaders('cookie'))
+        if co:
+            env['HTTP_COOKIE'] = ', '.join(co)
+        env['HTTP_AUTHORIZATION'] = self.headers.getheader('authorization')
+        env['SCRIPT_NAME'] = ''
+        env['SERVER_NAME'] = self.server.server_name
+        env['SERVER_PORT'] = str(self.server.server_port)
+        env['HTTP_HOST'] = self.headers['host']
+        if os.environ.has_key('CGI_SHOW_TIMING'):
+            env['CGI_SHOW_TIMING'] = os.environ['CGI_SHOW_TIMING']
+        env['HTTP_ACCEPT_LANGUAGE'] = self.headers.get('accept-language')
+
+        # do the roundup thing
+        tracker = self.get_tracker(tracker_name)
+        tracker.Client(tracker, self, env).main()
+
+    def address_string(self):
+        if self.LOG_IPADDRESS:
+            return self.client_address[0]
+        else:
+            host, port = self.client_address
+            return socket.getfqdn(host)
+
+    def log_message(self, format, *args):
+        ''' Try to *safely* log to stderr.
+        '''
+        try:
+            BaseHTTPServer.BaseHTTPRequestHandler.log_message(self,
+                format, *args)
+        except IOError:
+            # stderr is no longer viable
+            pass
+
+def error():
+    exc_type, exc_value = sys.exc_info()[:2]
+    return _('Error: %s: %s' % (exc_type, exc_value))
+
+def setgid(group):
+    if group is None:
+        return
+    if not hasattr(os, 'setgid'):
+        return
+
+    # if root, setgid to the running user
+    if os.getuid():
+        print _('WARNING: ignoring "-g" argument, not root')
+        return
+
+    try:
+        import grp
+    except ImportError:
+        raise ValueError, _("Can't change groups - no grp module")
+    try:
+        try:
+            gid = int(group)
+        except ValueError:
+            gid = grp.getgrnam(group)[2]
+        else:
+            grp.getgrgid(gid)
+    except KeyError:
+        raise ValueError,_("Group %(group)s doesn't exist")%locals()
+    os.setgid(gid)
+
+def setuid(user):
+    if not hasattr(os, 'getuid'):
+        return
+
+    # People can remove this check if they're really determined
+    if user is None:
+        if os.getuid():
+            return
+        raise ValueError, _("Can't run as root!")
+
+    if os.getuid():
+        print _('WARNING: ignoring "-u" argument, not root')
+
+    try:
+        import pwd
+    except ImportError:
+        raise ValueError, _("Can't change users - no pwd module")
+    try:
+        try:
+            uid = int(user)
+        except ValueError:
+            uid = pwd.getpwnam(user)[2]
+        else:
+            pwd.getpwuid(uid)
+    except KeyError:
+        raise ValueError, _("User %(user)s doesn't exist")%locals()
+    os.setuid(uid)
+
+class TrackerHomeOption(configuration.FilePathOption):
+
+    # Tracker homes do not need any description strings
+    def format(self):
+        return "%(name)s = %(value)s\n" % {
+                "name": self.setting,
+                "value": self.value2str(self._value),
+            }
+
+class ServerConfig(configuration.Config):
+
+    SETTINGS = (
+            ("main", (
+            (configuration.Option, "host", "",
+                "Host name of the Roundup web server instance.\n"
+                "If empty, listen on all network interfaces."),
+            (configuration.IntegerNumberOption, "port", DEFAULT_PORT,
+                "Port to listen on."),
+            (configuration.NullableFilePathOption, "favicon", "favicon.ico",
+                "Path to favicon.ico image file."
+                "  If unset, built-in favicon.ico is used."),
+            (configuration.NullableOption, "user", "",
+                "User ID as which the server will answer requests.\n"
+                "In order to use this option, "
+                "the server must be run initially as root.\n"
+                "Availability: Unix."),
+            (configuration.NullableOption, "group", "",
+                "Group ID as which the server will answer requests.\n"
+                "In order to use this option, "
+                "the server must be run initially as root.\n"
+                "Availability: Unix."),
+            (configuration.BooleanOption, "log_hostnames", "no",
+                "Log client machine names instead of IP addresses "
+                "(much slower)"),
+            (configuration.NullableFilePathOption, "pidfile", "",
+                "File to which the server records "
+                "the process id of the daemon.\n"
+                "If this option is not set, "
+                "the server will run in foreground\n"),
+            (configuration.NullableFilePathOption, "logfile", "",
+                "Log file path.  If unset, log to stderr."),
+            (configuration.Option, "multiprocess", DEFAULT_MULTIPROCESS,
+                "Set processing of each request in separate subprocess.\n"
+                "Allowed values: %s." % ", ".join(MULTIPROCESS_TYPES)),
+        )),
+        ("trackers", (), "Roundup trackers to serve.\n"
+            "Each option in this section defines single Roundup tracker.\n"
+            "Option name identifies the tracker and will appear in the URL.\n"
+            "Option value is tracker home directory path.\n"
+            "The path may be either absolute or relative\n"
+            "to the directory containig this config file."),
+    )
+
+    # options recognized by config
+    OPTIONS = {
+        "host": "n:",
+        "port": "p:",
+        "group": "g:",
+        "user": "u:",
+        "logfile": "l:",
+        "pidfile": "d:",
+        "log_hostnames": "N",
+        "multiprocess": "t:",
+    }
+
+    def __init__(self, config_file=None):
+        configuration.Config.__init__(self, config_file, self.SETTINGS)
+        self.sections.append("trackers")
+
+    def _adjust_options(self, config):
+        """Add options for tracker homes"""
+        # return early if there are no tracker definitions.
+        # trackers must be specified on the command line.
+        if not config.has_section("trackers"):
+            return
+        # config defaults appear in all sections.
+        # filter them out.
+        defaults = config.defaults().keys()
+        for name in config.options("trackers"):
+            if name not in defaults:
+                self.add_option(TrackerHomeOption(self, "trackers", name))
+
+    def getopt(self, args, short_options="", long_options=(),
+        config_load_options=("C", "config"), **options
+    ):
+        options.update(self.OPTIONS)
+        return configuration.Config.getopt(self, args,
+            short_options, long_options, config_load_options, **options)
+
+    def _get_name(self):
+        return "Roundup server"
+
+    def trackers(self):
+        """Return tracker definitions as a list of (name, home) pairs"""
+        trackers = []
+        for option in self._get_section_options("trackers"):
+            trackers.append((option, os.path.abspath(
+                self["TRACKERS_" + option.upper()])))
+        return trackers
+
+    def set_logging(self):
+        """Initialise logging to the configured file, if any."""
+        # appending, unbuffered
+        sys.stdout = sys.stderr = open(self["LOGFILE"], 'a', 0)
+
+    def get_server(self):
+        """Return HTTP server object to run"""
+        # we don't want the cgi module interpreting the command-line args ;)
+        sys.argv = sys.argv[:1]
+
+        # preload all trackers unless we are in "debug" mode
+        tracker_homes = self.trackers()
+        if self["MULTIPROCESS"] == "debug":
+            trackers = None
+        else:
+            trackers = dict([(name, roundup.instance.open(home, optimize=1))
+                for (name, home) in tracker_homes])
+
+        # build customized request handler class
+        class RequestHandler(RoundupRequestHandler):
+            LOG_IPADDRESS = not self["LOG_HOSTNAMES"]
+            TRACKER_HOMES = dict(tracker_homes)
+            TRACKERS = trackers
+            DEBUG_MODE = self["MULTIPROCESS"] == "debug"
+            CONFIG = self
+
+        # obtain request server class
+        if self["MULTIPROCESS"] not in MULTIPROCESS_TYPES:
+            print _("Multiprocess mode \"%s\" is not available, "
+                "switching to single-process") % self["MULTIPROCESS"]
+            self["MULTIPROCESS"] = "none"
+            server_class = BaseHTTPServer.HTTPServer
+        elif self["MULTIPROCESS"] == "fork":
+            class ForkingServer(SocketServer.ForkingMixIn,
+                BaseHTTPServer.HTTPServer):
+                    pass
+            server_class = ForkingServer
+        elif self["MULTIPROCESS"] == "thread":
+            class ThreadingServer(SocketServer.ThreadingMixIn,
+                BaseHTTPServer.HTTPServer):
+                    pass
+            server_class = ThreadingServer
+        else:
+            server_class = BaseHTTPServer.HTTPServer
+        # obtain server before changing user id - allows to
+        # use port < 1024 if started as root
+        try:
+            httpd = server_class((self["HOST"], self["PORT"]), RequestHandler)
+        except socket.error, e:
+            if e[0] == errno.EADDRINUSE:
+                raise socket.error, \
+                    _("Unable to bind to port %s, port already in use.") \
+                    % self["PORT"]
+            raise
+        # change user and/or group
+        setgid(self["GROUP"])
+        setuid(self["USER"])
+        # return the server
+        return httpd
+
+try:
+    import win32serviceutil
+except:
+    RoundupService = None
+else:
+
+    # allow the win32
+    import win32service
+
+    class SvcShutdown(Exception):
+        pass
+
+    class RoundupService(win32serviceutil.ServiceFramework):
+
+        _svc_name_ = "roundup"
+        _svc_display_name_ = "Roundup Bug Tracker"
+
+        running = 0
+        server = None
+
+        def SvcDoRun(self):
+            import servicemanager
+            self.ReportServiceStatus(win32service.SERVICE_START_PENDING)
+            config = ServerConfig()
+            (optlist, args) = config.getopt(sys.argv[1:])
+            if not config["LOGFILE"]:
+                servicemanager.LogMsg(servicemanager.EVENTLOG_ERROR_TYPE,
+                    servicemanager.PYS_SERVICE_STOPPED,
+                    (self._svc_display_name_, "\r\nMissing logfile option"))
+                self.ReportServiceStatus(win32service.SERVICE_STOPPED)
+                return
+            config.set_logging()
+            self.server = config.get_server()
+            self.running = 1
+            self.ReportServiceStatus(win32service.SERVICE_RUNNING)
+            servicemanager.LogMsg(servicemanager.EVENTLOG_INFORMATION_TYPE,
+                servicemanager.PYS_SERVICE_STARTED, (self._svc_display_name_,
+                    " at %s:%s" % (config["HOST"], config["PORT"])))
+            while self.running:
+                self.server.handle_request()
+            servicemanager.LogMsg(servicemanager.EVENTLOG_INFORMATION_TYPE,
+                servicemanager.PYS_SERVICE_STOPPED,
+                (self._svc_display_name_, ""))
+            self.ReportServiceStatus(win32service.SERVICE_STOPPED)
+
+        def SvcStop(self):
+            self.running = 0
+            # make dummy connection to self to terminate blocking accept()
+            addr = self.server.socket.getsockname()
+            if addr[0] == "0.0.0.0":
+                addr = ("127.0.0.1", addr[1])
+            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+            sock.connect(addr)
+            sock.close()
+            self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
+
+def usage(message=''):
+    if RoundupService:
+        os_part = \
+""''' -c <Command>  Windows Service options.
+               If you want to run the server as a Windows Service, you
+               must use configuration file to specify tracker homes.
+               Logfile option is required to run Roundup Tracker service.
+               Typing "roundup-server -c help" shows Windows Services
+               specifics.'''
+    else:
+        os_part = ""''' -u <UID>      runs the Roundup web server as this UID
+ -g <GID>      runs the Roundup web server as this GID
+ -d <PIDfile>  run the server in the background and write the server's PID
+               to the file indicated by PIDfile. The -l option *must* be
+               specified if -d is used.'''
+    if message:
+        message += '\n'
+    print _('''%(message)sUsage: roundup-server [options] [name=tracker home]*
+
+Options:
+ -v            print the Roundup version number and exit
+ -h            print this text and exit
+ -S            create or update configuration file and exit
+ -C <fname>    use configuration file <fname>
+ -n <name>     set the host name of the Roundup web server instance
+ -p <port>     set the port to listen on (default: %(port)s)
+ -l <fname>    log to the file indicated by fname instead of stderr/stdout
+ -N            log client machine names instead of IP addresses (much slower)
+ -t <mode>     multiprocess mode (default: %(mp_def)s).
+               Allowed values: %(mp_types)s.
+%(os_part)s
+
+Long options:
+ --version          print the Roundup version number and exit
+ --help             print this text and exit
+ --save-config      create or update configuration file and exit
+ --config <fname>   use configuration file <fname>
+ All settings of the [main] section of the configuration file
+ also may be specified in form --<name>=<value>
+
+Examples:
+
+ roundup-server -S -C /opt/roundup/etc/roundup-server.ini \\
+    -n localhost -p 8917 -l /var/log/roundup.log \\
+    support=/var/spool/roundup-trackers/support
+
+ roundup-server -C /opt/roundup/etc/roundup-server.ini
+
+ roundup-server support=/var/spool/roundup-trackers/support
+
+ roundup-server -d /var/run/roundup.pid -l /var/log/roundup.log \\
+    support=/var/spool/roundup-trackers/support
+
+Configuration file format:
+   Roundup Server configuration file has common .ini file format.
+   Configuration file created with 'roundup-server -S' contains
+   detailed explanations for each option.  Please see that file
+   for option descriptions.
+
+How to use "name=tracker home":
+   These arguments set the tracker home(s) to use. The name is how the
+   tracker is identified in the URL (it's the first part of the URL path).
+   The tracker home is the directory that was identified when you did
+   "roundup-admin init". You may specify any number of these name=home
+   pairs on the command-line. Make sure the name part doesn't include
+   any url-unsafe characters like spaces, as these confuse IE.
+''') % {
+    "message": message,
+    "os_part": os_part,
+    "port": DEFAULT_PORT,
+    "mp_def": DEFAULT_MULTIPROCESS,
+    "mp_types": ", ".join(MULTIPROCESS_TYPES),
+}
+
+
+def daemonize(pidfile):
+    ''' Turn this process into a daemon.
+        - make sure the sys.std(in|out|err) are completely cut off
+        - make our parent PID 1
+
+        Write our new PID to the pidfile.
+
+        From A.M. Kuuchling (possibly originally Greg Ward) with
+        modification from Oren Tirosh, and finally a small mod from me.
+    '''
+    # Fork once
+    if os.fork() != 0:
+        os._exit(0)
+
+    # Create new session
+    os.setsid()
+
+    # Second fork to force PPID=1
+    pid = os.fork()
+    if pid:
+        pidfile = open(pidfile, 'w')
+        pidfile.write(str(pid))
+        pidfile.close()
+        os._exit(0)
+
+    os.chdir("/")
+
+    # close off std(in|out|err), redirect to devnull so the file
+    # descriptors can't be used again
+    devnull = os.open('/dev/null', 0)
+    os.dup2(devnull, 0)
+    os.dup2(devnull, 1)
+    os.dup2(devnull, 2)
+
+undefined = []
+def run(port=undefined, success_message=None):
+    ''' Script entry point - handle args and figure out what to to.
+    '''
+    # time out after a minute if we can
+    if hasattr(socket, 'setdefaulttimeout'):
+        socket.setdefaulttimeout(60)
+
+    config = ServerConfig()
+    # additional options
+    short_options = "hvS"
+    if RoundupService:
+        short_options += 'c'
+    try:
+        (optlist, args) = config.getopt(sys.argv[1:],
+            short_options, ("help", "version", "save-config",))
+    except (getopt.GetoptError, configuration.ConfigurationError), e:
+        usage(str(e))
+        return
+
+    # if running in windows service mode, don't do any other stuff
+    if ("-c", "") in optlist:
+        # acquire command line options recognized by service
+        short_options = "cC:"
+        long_options = ["config"]
+        for (long_name, short_name) in config.OPTIONS.items():
+            short_options += short_name
+            long_name = long_name.lower().replace("_", "-")
+            if short_name[-1] == ":":
+                long_name += "="
+            long_options.append(long_name)
+        optlist = getopt.getopt(sys.argv[1:], short_options, long_options)[0]
+        svc_args = []
+        for (opt, arg) in optlist:
+            if opt in ("-C", "-l"):
+                # make sure file name is absolute
+                svc_args.extend((opt, os.path.abspath(arg)))
+            elif opt in ("--config", "--logfile"):
+                # ditto, for long options
+                svc_args.append("=".join(opt, os.path.abspath(arg)))
+            elif opt != "-c":
+                svc_args.extend(opt)
+        RoundupService._exe_args_ = " ".join(svc_args)
+        # pass the control to serviceutil
+        win32serviceutil.HandleCommandLine(RoundupService,
+            argv=sys.argv[:1] + args)
+        return
+
+    # add tracker names from command line.
+    # this is done early to let '--save-config' handle the trackers.
+    if args:
+        for arg in args:
+            try:
+                name, home = arg.split('=')
+            except ValueError:
+                raise ValueError, _("Instances must be name=home")
+            config.add_option(TrackerHomeOption(config, "trackers", name))
+            config["TRACKERS_" + name.upper()] = home
+
+    # handle remaining options
+    if optlist:
+        for (opt, arg) in optlist:
+            if opt in ("-h", "--help"):
+                usage()
+            elif opt in ("-v", "--version"):
+                print '%s (python %s)' % (roundup_version,
+                    sys.version.split()[0])
+            elif opt in ("-S", "--save-config"):
+                config.save()
+                print _("Configuration saved to %s") % config.filepath
+        # any of the above options prevent server from running
+        return
+
+    # port number in function arguments overrides config and command line
+    if port is not undefined:
+        config.PORT = port
+
+    if config["LOGFILE"]:
+        config["LOGFILE"] = os.path.abspath(config["LOGFILE"])
+        # switch logging from stderr/stdout to logfile
+        config.set_logging()
+    if config["PIDFILE"]:
+        config["PIDFILE"] = os.path.abspath(config["PIDFILE"])
+
+    # fork the server from our parent if a pidfile is specified
+    if config["PIDFILE"]:
+        if not hasattr(os, 'fork'):
+            print _("Sorry, you can't run the server as a daemon"
+                " on this Operating System")
+            sys.exit(0)
+        else:
+            daemonize(config["PIDFILE"])
+
+    # create the server
+    httpd = config.get_server()
+
+    if success_message:
+        print success_message
+    else:
+        print _('Roundup server started on %(HOST)s:%(PORT)s') \
+            % config
+
+    try:
+        httpd.serve_forever()
+    except KeyboardInterrupt:
+        print 'Keyboard Interrupt: exiting'
+
+if __name__ == '__main__':
+    run()
+
+# vim: set filetype=python sts=4 sw=4 et si :

Added: tracker/vendor/roundup/current/roundup/security.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/security.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,218 @@
+"""Handle the security declarations used in Roundup trackers.
+"""
+__docformat__ = 'restructuredtext'
+
+import weakref
+
+from roundup import hyperdb, support
+
+class Permission:
+    ''' Defines a Permission with the attributes
+        - name
+        - description
+        - klass (optional)
+        - properties (optional)
+        - check function (optional)
+
+        The klass may be unset, indicating that this permission is not
+        locked to a particular class. That means there may be multiple
+        Permissions for the same name for different classes.
+
+        If property names are set, permission is restricted to those
+        properties only.
+
+        If check function is set, permission is granted only when
+        the function returns value interpreted as boolean true.
+        The function is called with arguments db, userid, itemid.
+    '''
+    def __init__(self, name='', description='', klass=None,
+            properties=None, check=None):
+        self.name = name
+        self.description = description
+        self.klass = klass
+        self.properties = properties
+        self._properties_dict = support.TruthDict(properties)
+        self.check = check
+
+    def test(self, db, permission, classname, property, userid, itemid):
+        if permission != self.name:
+            return 0
+
+        # are we checking the correct class
+        if self.klass is not None and self.klass != classname:
+            return 0
+
+        # what about property?
+        if property is not None and not self._properties_dict[property]:
+            return 0
+
+        # check code
+        if itemid is not None and self.check is not None:
+            if not self.check(db, userid, itemid):
+                return 0
+
+        # we have a winner
+        return 1
+
+    def __repr__(self):
+        return '<Permission 0x%x %r,%r,%r,%r>'%(id(self), self.name,
+            self.klass, self.properties, self.check)
+
+    def __cmp__(self, other):
+        if self.name != other.name:
+            return cmp(self.name, other.name)
+
+        if self.klass != other.klass: return 1
+        if self.properties != other.properties: return 1
+        if self.check != other.check: return 1
+
+        # match
+        return 0
+
+class Role:
+    ''' Defines a Role with the attributes
+        - name
+        - description
+        - permissions
+    '''
+    def __init__(self, name='', description='', permissions=None):
+        self.name = name.lower()
+        self.description = description
+        if permissions is None:
+            permissions = []
+        self.permissions = permissions
+
+    def __repr__(self):
+        return '<Role 0x%x %r,%r>'%(id(self), self.name, self.permissions)
+
+class Security:
+    def __init__(self, db):
+        ''' Initialise the permission and role classes, and add in the
+            base roles (for admin user).
+        '''
+        self.db = weakref.proxy(db)       # use a weak ref to avoid circularity
+
+        # permssions are mapped by name to a list of Permissions by class
+        self.permission = {}
+
+        # roles are mapped by name to the Role
+        self.role = {}
+
+        # the default Roles
+        self.addRole(name="User", description="A regular user, no privs")
+        self.addRole(name="Admin", description="An admin user, full privs")
+        self.addRole(name="Anonymous", description="An anonymous user")
+
+        ce = self.addPermission(name="Create",
+            description="User may create everthing")
+        self.addPermissionToRole('Admin', ce)
+        ee = self.addPermission(name="Edit",
+            description="User may edit everthing")
+        self.addPermissionToRole('Admin', ee)
+        ae = self.addPermission(name="View",
+            description="User may access everything")
+        self.addPermissionToRole('Admin', ae)
+
+        # initialise the permissions and roles needed for the UIs
+        from roundup.cgi import client
+        client.initialiseSecurity(self)
+        from roundup import mailgw
+        mailgw.initialiseSecurity(self)
+
+    def getPermission(self, permission, classname=None, properties=None,
+            check=None):
+        ''' Find the Permission matching the name and for the class, if the
+            classname is specified.
+
+            Raise ValueError if there is no exact match.
+        '''
+        if not self.permission.has_key(permission):
+            raise ValueError, 'No permission "%s" defined'%permission
+
+        if classname:
+            try:
+                self.db.getclass(classname)
+            except KeyError:
+                raise ValueError, 'No class "%s" defined'%classname
+
+        # look through all the permissions of the given name
+        tester = Permission(permission, klass=classname, properties=properties,
+            check=check)
+        for perm in self.permission[permission]:
+            if perm == tester:
+                return perm
+        raise ValueError, 'No permission "%s" defined for "%s"'%(permission,
+            classname)
+
+    def hasPermission(self, permission, userid, classname=None,
+            property=None, itemid=None):
+        '''Look through all the Roles, and hence Permissions, and
+           see if "permission" exists given the constraints of
+           classname, property and itemid.
+
+           If classname is specified (and only classname) then the
+           search will match if there is *any* Permission for that
+           classname, even if the Permission has additional
+           constraints.
+
+           If property is specified, the Permission matched must have
+           either no properties listed or the property must appear in
+           the list.
+
+           If itemid is specified, the Permission matched must have
+           either no check function defined or the check function,
+           when invoked, must return a True value.
+
+           Note that this functionality is actually implemented by the
+           Permission.test() method.
+        '''
+        roles = self.db.user.get(userid, 'roles')
+        if roles is None:
+            return 0
+        if itemid and classname is None:
+            raise ValueError, 'classname must accompany itemid'
+        for rolename in [x.lower().strip() for x in roles.split(',')]:
+            if not rolename or not self.role.has_key(rolename):
+                continue
+            # for each of the user's Roles, check the permissions
+            for perm in self.role[rolename].permissions:
+                # permission match?
+                if perm.test(self.db, permission, classname, property,
+                        userid, itemid):
+                    return 1
+        return 0
+
+    def addPermission(self, **propspec):
+        ''' Create a new Permission with the properties defined in
+            'propspec'. See the Permission class for the possible
+            keyword args.
+        '''
+        perm = Permission(**propspec)
+        self.permission.setdefault(perm.name, []).append(perm)
+        return perm
+
+    def addRole(self, **propspec):
+        ''' Create a new Role with the properties defined in 'propspec'
+        '''
+        role = Role(**propspec)
+        self.role[role.name] = role
+        return role
+
+    def addPermissionToRole(self, rolename, permission, classname=None,
+            properties=None, check=None):
+        ''' Add the permission to the role's permission list.
+
+            'rolename' is the name of the role to add the permission to.
+
+            'permission' is either a Permission *or* a permission name
+            accompanied by 'classname' (thus in the second case a Permission
+            is obtained by passing 'permission' and 'classname' to
+            self.getPermission)
+        '''
+        if not isinstance(permission, Permission):
+            permission = self.getPermission(permission, classname,
+                properties, check)
+        role = self.role[rolename.lower()]
+        role.permissions.append(permission)
+
+# vim: set filetype=python sts=4 sw=4 et si :

Added: tracker/vendor/roundup/current/roundup/support.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/support.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,121 @@
+"""Implements various support classes and functions used in a number of
+places in Roundup code.
+"""
+
+__docformat__ = 'restructuredtext'
+
+import os, time, sys
+
+class TruthDict:
+    '''Returns True for valid keys, False for others.
+    '''
+    def __init__(self, keys):
+        if keys:
+            self.keys = {}
+            for col in keys:
+                self.keys[col] = 1
+        else:
+            self.__getitem__ = lambda name: 1
+
+    def __getitem__(self, name):
+        return self.keys.has_key(name)
+
+def ensureParentsExist(dest):
+    if not os.path.exists(os.path.dirname(dest)):
+        os.makedirs(os.path.dirname(dest))
+
+class PrioList:
+    '''Manages a sorted list.
+
+    Currently only implements method 'append' and iteration from a
+    full list interface.
+    Implementation: We manage a "sorted" status and sort on demand.
+    Appending to the list will require re-sorting before use.
+    >>> p = PrioList ()
+    >>> for i in 5,7,1,-1 :
+    ...  p.append (i)
+    ...
+    >>> for k in p :
+    ...  print k
+    ...
+    -1
+    1
+    5
+    7
+
+    '''
+    def __init__(self):
+        self.list   = []
+        self.sorted = True
+
+    def append(self, item):
+        self.list.append (item)
+        self.sorted = False
+
+    def __iter__(self):
+        if not self.sorted :
+            self.list.sort ()
+            self.sorted = True
+        return iter (self.list)
+
+class Progress:
+    '''Progress display for console applications.
+
+    See __main__ block at end of file for sample usage.
+    '''
+    def __init__(self, info, sequence):
+        self.info = info
+        self.sequence = iter(sequence)
+        self.total = len(sequence)
+        self.start = self.now = time.time()
+        self.num = 0
+        self.stepsize = self.total / 100 or 1
+        self.steptimes = []
+        self.display()
+
+    def __iter__(self): return self
+
+    def next(self):
+        self.num += 1
+
+        if self.num > self.total:
+            print self.info, 'done', ' '*(75-len(self.info)-6)
+            sys.stdout.flush()
+            return self.sequence.next()
+
+        if self.num % self.stepsize:
+            return self.sequence.next()
+
+        self.display()
+        return self.sequence.next()
+
+    def display(self):
+        # figure how long we've spent - guess how long to go
+        now = time.time()
+        steptime = now - self.now
+        self.steptimes.insert(0, steptime)
+        if len(self.steptimes) > 5:
+            self.steptimes.pop()
+        steptime = sum(self.steptimes) / len(self.steptimes)
+        self.now = now
+        eta = steptime * ((self.total - self.num)/self.stepsize)
+
+        # tell it like it is (or might be)
+        if now - self.start > 3:
+            M = eta / 60
+            H = M / 60
+            M = M % 60
+            S = eta % 60
+            if self.total:
+                s = '%s %2d%% (ETA %02d:%02d:%02d)'%(self.info,
+                    self.num * 100. / self.total, H, M, S)
+            else:
+                s = '%s 0%% (ETA %02d:%02d:%02d)'%(self.info, H, M, S)
+        elif self.total:
+            s = '%s %2d%%'%(self.info, self.num * 100. / self.total)
+        else:
+            s = '%s %d done'%(self.info, self.num)
+        sys.stdout.write(s + ' '*(75-len(s)) + '\r')
+        sys.stdout.flush()
+
+# vim: set et sts=4 sw=4 :

Added: tracker/vendor/roundup/current/roundup/token.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/token.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,118 @@
+#
+# Copyright (c) 2001 Richard Jones, richard at bofh.asn.au.
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# This module 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.
+# 
+# $Id: token.py,v 1.4 2004/02/11 23:55:08 richard Exp $
+#
+
+"""This module provides the tokeniser used by roundup-admin.
+"""
+__docformat__ = 'restructuredtext'
+
+def token_split(s, whitespace=' \r\n\t', quotes='\'"',
+        escaped={'r':'\r', 'n':'\n', 't':'\t'}):
+    '''Split the string up into tokens. An occurence of a ``'`` or ``"`` in
+    the input will cause the splitter to ignore whitespace until a matching
+    quote char is found. Embedded non-matching quote chars are also skipped.
+
+    Whitespace and quoting characters may be escaped using a backslash.
+    ``\r``, ``\n`` and ``\t`` are converted to carriage-return, newline and
+    tab.  All other backslashed characters are left as-is.
+
+    Valid examples::
+
+           hello world      (2 tokens: hello, world)
+           "hello world"    (1 token: hello world)
+           "Roch'e" Compaan (2 tokens: Roch'e Compaan)
+           Roch\'e Compaan  (2 tokens: Roch'e Compaan)
+           address="1 2 3"  (1 token: address=1 2 3)
+           \\               (1 token: \)
+           \n               (1 token: a newline)
+           \o               (1 token: \o)
+
+    Invalid examples::
+
+           "hello world     (no matching quote)
+           Roch'e Compaan   (no matching quote)
+    '''
+    l = []
+    pos = 0
+    NEWTOKEN = 'newtoken'
+    TOKEN = 'token'
+    QUOTE = 'quote'
+    ESCAPE = 'escape'
+    quotechar = ''
+    state = NEWTOKEN
+    oldstate = ''    # one-level state stack ;)
+    length = len(s)
+    finish = 0
+    token = ''
+    while 1:
+        # end of string, finish off the current token
+        if pos == length:
+            if state == QUOTE: raise ValueError, "unmatched quote"
+            elif state == TOKEN: l.append(token)
+            break
+        c = s[pos]
+        if state == NEWTOKEN:
+            # looking for a new token
+            if c in quotes:
+                # quoted token
+                state = QUOTE
+                quotechar = c
+                pos = pos + 1
+                continue
+            elif c in whitespace:
+                # skip whitespace
+                pos = pos + 1
+                continue
+            elif c == '\\':
+                pos = pos + 1
+                oldstate = TOKEN
+                state = ESCAPE
+                continue
+            # otherwise we have a token
+            state = TOKEN
+        elif state == TOKEN:
+            if c in whitespace:
+                # have a token, and have just found a whitespace terminator
+                l.append(token)
+                pos = pos + 1
+                state = NEWTOKEN
+                token = ''
+                continue
+            elif c in quotes:
+                # have a token, just found embedded quotes
+                state = QUOTE
+                quotechar = c
+                pos = pos + 1
+                continue
+            elif c == '\\':
+                pos = pos + 1
+                oldstate = state
+                state = ESCAPE
+                continue
+        elif state == QUOTE and c == quotechar:
+            # in a quoted token and found a matching quote char
+            pos = pos + 1
+            # now we're looking for whitespace
+            state = TOKEN
+            continue
+        elif state == ESCAPE:
+            # escaped-char conversions (t, r, n)
+            # TODO: octal, hexdigit
+            state = oldstate
+            if escaped.has_key(c):
+                c = escaped[c]
+        # just add this char to the token and move along
+        token = token + c
+        pos = pos + 1
+    return l
+
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/roundup/version_check.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/roundup/version_check.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,31 @@
+#!/usr/bin/env python
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+# 
+# $Id: version_check.py,v 1.4 2004/02/11 23:55:08 richard Exp $
+
+"""Enforces the minimum Python version that Roundup requires.
+"""
+__docformat__ = 'restructuredtext'
+
+import sys
+if not hasattr(sys, 'version_info') or sys.version_info[:3] < (2,1,1):
+    print "Content-Type: text/plain\n"
+    print "Roundup requires Python 2.1.1 or newer."
+    sys.exit(0)
+
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/run_tests.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/run_tests.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,887 @@
+#! /usr/bin/env python
+##############################################################################
+#
+# Copyright (c) 2001, 2002 Zope Corporation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.0 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+"""
+test.py [-aBbcdDfgGhLmprtTuv] [modfilter [testfilter]]
+
+Test harness.
+
+-a level
+--all
+    Run the tests at the given level.  Any test at a level at or below this is
+    run, any test at a level above this is not run.  Level 0 runs all tests.
+    The default is to run tests at level 1.  --all is a shortcut for -a 0.
+
+-b
+--build
+    Run "python setup.py build" before running tests, where "python"
+    is the version of python used to run test.py.  Highly recommended.
+    Tests will be run from the build directory.  (Note: In Python < 2.3
+    the -q flag is added to the setup.py command line.)
+
+-B
+    Run "python setup.py build_ext -i" before running tests.  Tests will be
+    run from the source directory.
+
+-c  use pychecker
+
+-d
+    Instead of the normal test harness, run a debug version which
+    doesn't catch any exceptions.  This is occasionally handy when the
+    unittest code catching the exception doesn't work right.
+    Unfortunately, the debug harness doesn't print the name of the
+    test, so Use With Care.
+
+--dir directory
+    Option to limit where tests are searched for. This is
+    important when you *really* want to limit the code that gets run.
+    For example, if refactoring interfaces, you don't want to see the way
+    you have broken setups for tests in other packages. You *just* want to
+    run the interface tests.
+
+-D
+    Works like -d, except that it loads pdb when an exception occurs.
+
+-f
+    Run functional tests instead of unit tests.
+
+-g threshold
+    Set the garbage collector generation0 threshold.  This can be used to
+    stress memory and gc correctness.  Some crashes are only reproducible when
+    the threshold is set to 1 (agressive garbage collection).  Do "-g 0" to
+    disable garbage collection altogether.
+
+-G gc_option
+    Set the garbage collection debugging flags.  The argument must be one
+    of the DEBUG_ flags defined bythe Python gc module.  Multiple options
+    can be specified by using "-G OPTION1 -G OPTION2."
+
+--libdir test_root
+    Search for tests starting in the specified start directory
+    (useful for testing components being developed outside the main
+    "src" or "build" trees).
+
+--keepbytecode
+    Do not delete all stale bytecode before running tests
+
+-L
+    Keep running the selected tests in a loop.  You may experience
+    memory leakage.
+
+-t
+    Time the individual tests and print a list of the top 50, sorted from
+    longest to shortest.
+
+-p
+    Show running progress.  It can be combined with -v or -vv.
+
+-r
+    Look for refcount problems.
+    This requires that Python was built --with-pydebug.
+
+-T
+    Use the trace module from Python for code coverage.  XXX This only works
+    if trace.py is explicitly added to PYTHONPATH.  The current utility writes
+    coverage files to a directory named `coverage' that is parallel to
+    `build'.  It also prints a summary to stdout.
+
+-v
+    Verbose output.  With one -v, unittest prints a dot (".") for each test
+    run.  With -vv, unittest prints the name of each test (for some definition
+    of "name" ...).  With no -v, unittest is silent until the end of the run,
+    except when errors occur.
+
+    When -p is also specified, the meaning of -v is sligtly changed.  With
+    -p and no -v only the percent indicator is displayed.  With -p and -v
+    the test name of the current test is shown to the right of the percent
+    indicator.  With -p and -vv the test name is not truncated to fit into
+    80 columns and it is not cleared after the test finishes.
+
+-u
+-m
+    Use the PyUnit GUI instead of output to the command line.  The GUI imports
+    tests on its own, taking care to reload all dependencies on each run.  The
+    debug (-d), verbose (-v), progress (-p), and Loop (-L) options will be
+    ignored.  The testfilter filter is also not applied.
+
+    -m starts the gui minimized.  Double-clicking the progress bar will start
+    the import and run all tests.
+
+
+modfilter
+testfilter
+    Case-sensitive regexps to limit which tests are run, used in search
+    (not match) mode.
+    In an extension of Python regexp notation, a leading "!" is stripped
+    and causes the sense of the remaining regexp to be negated (so "!bc"
+    matches any string that does not match "bc", and vice versa).
+    By default these act like ".", i.e. nothing is excluded.
+
+    modfilter is applied to a test file's path, starting at "build" and
+    including (OS-dependent) path separators.
+
+    testfilter is applied to the (method) name of the unittest methods
+    contained in the test files whose paths modfilter matched.
+
+Extreme (yet useful) examples:
+
+    test.py -vvb . "^testWriteClient$"
+
+    Builds the project silently, then runs unittest in verbose mode on all
+    tests whose names are precisely "testWriteClient".  Useful when
+    debugging a specific test.
+
+    test.py -vvb . "!^testWriteClient$"
+
+    As before, but runs all tests whose names aren't precisely
+    "testWriteClient".  Useful to avoid a specific failing test you don't
+    want to deal with just yet.
+
+    test.py -m . "!^testWriteClient$"
+
+    As before, but now opens up a minimized PyUnit GUI window (only showing
+    the progress bar).  Useful for refactoring runs where you continually want
+    to make sure all tests still pass.
+"""
+
+import gc
+import os
+import re
+import pdb
+import sys
+import threading    # just to get at Thread objects created by tests
+import time
+import traceback
+import unittest
+import warnings
+
+from distutils.util import get_platform
+
+PLAT_SPEC = "%s-%s" % (get_platform(), sys.version[0:3])
+
+class ImmediateTestResult(unittest._TextTestResult):
+
+    __super_init = unittest._TextTestResult.__init__
+    __super_startTest = unittest._TextTestResult.startTest
+    __super_printErrors = unittest._TextTestResult.printErrors
+
+    def __init__(self, stream, descriptions, verbosity, debug=0,
+                 count=None, progress=0):
+        self.__super_init(stream, descriptions, verbosity)
+        self._debug = debug
+        self._progress = progress
+        self._progressWithNames = 0
+        self._count = count
+        self._testtimes = {}
+        if progress and verbosity == 1:
+            self.dots = 0
+            self._progressWithNames = 1
+            self._lastWidth = 0
+            self._maxWidth = 80
+            try:
+                import curses
+            except ImportError:
+                pass
+            else:
+                curses.setupterm()
+                self._maxWidth = curses.tigetnum('cols')
+            self._maxWidth -= len("xxxx/xxxx (xxx.x%): ") + 1
+
+    def stopTest(self, test):
+        self._testtimes[test] = time.time() - self._testtimes[test]
+        if gc.garbage:
+            print "The following test left garbage:"
+            print test
+            print gc.garbage
+            # eat the garbage here, so that the garbage isn't
+            # printed for every subsequent test.
+            gc.garbage[:] = []
+
+        # Did the test leave any new threads behind?
+        new_threads = [t for t in threading.enumerate()
+                         if (t.isAlive()
+                             and
+                             t not in self._threads)]
+        if new_threads:
+            print "The following test left new threads behind:"
+            print test
+            print "New thread(s):", new_threads
+
+    def print_times(self, stream, count=None):
+        results = self._testtimes.items()
+        results.sort(lambda x, y: cmp(y[1], x[1]))
+        if count:
+            n = min(count, len(results))
+            if n:
+                print >>stream, "Top %d longest tests:" % n
+        else:
+            n = len(results)
+        if not n:
+            return
+        for i in range(n):
+            print >>stream, "%6dms" % int(results[i][1] * 1000), results[i][0]
+
+    def _print_traceback(self, msg, err, test, errlist):
+        if self.showAll or self.dots or self._progress:
+            self.stream.writeln("\n")
+            self._lastWidth = 0
+
+        tb = "".join(traceback.format_exception(*err))
+        self.stream.writeln(msg)
+        self.stream.writeln(tb)
+        errlist.append((test, tb))
+
+    def startTest(self, test):
+        if self._progress:
+            self.stream.write("\r%4d" % (self.testsRun + 1))
+            if self._count:
+                self.stream.write("/%d (%5.1f%%)" % (self._count,
+                                  (self.testsRun + 1) * 100.0 / self._count))
+            if self.showAll:
+                self.stream.write(": ")
+            elif self._progressWithNames:
+                # XXX will break with multibyte strings
+                name = self.getShortDescription(test)
+                width = len(name)
+                if width < self._lastWidth:
+                    name += " " * (self._lastWidth - width)
+                self.stream.write(": %s" % name)
+                self._lastWidth = width
+            self.stream.flush()
+        self._threads = threading.enumerate()
+        self.__super_startTest(test)
+        self._testtimes[test] = time.time()
+
+    def getShortDescription(self, test):
+        s = self.getDescription(test)
+        if len(s) > self._maxWidth:
+            pos = s.find(" (")
+            if pos >= 0:
+                w = self._maxWidth - (pos + 5)
+                if w < 1:
+                    # first portion (test method name) is too long
+                    s = s[:self._maxWidth-3] + "..."
+                else:
+                    pre = s[:pos+2]
+                    post = s[-w:]
+                    s = "%s...%s" % (pre, post)
+        return s[:self._maxWidth]
+
+    def addError(self, test, err):
+        if self._progress:
+            self.stream.write("\r")
+        if self._debug:
+            raise err[0], err[1], err[2]
+        self._print_traceback("Error in test %s" % test, err,
+                              test, self.errors)
+
+    def addFailure(self, test, err):
+        if self._progress:
+            self.stream.write("\r")
+        if self._debug:
+            raise err[0], err[1], err[2]
+        self._print_traceback("Failure in test %s" % test, err,
+                              test, self.failures)
+
+    def printErrors(self):
+        if self._progress and not (self.dots or self.showAll):
+            self.stream.writeln()
+        self.__super_printErrors()
+
+    def printErrorList(self, flavor, errors):
+        for test, err in errors:
+            self.stream.writeln(self.separator1)
+            self.stream.writeln("%s: %s" % (flavor, self.getDescription(test)))
+            self.stream.writeln(self.separator2)
+            self.stream.writeln(err)
+
+
+class ImmediateTestRunner(unittest.TextTestRunner):
+
+    __super_init = unittest.TextTestRunner.__init__
+
+    def __init__(self, **kwarg):
+        debug = kwarg.get("debug")
+        if debug is not None:
+            del kwarg["debug"]
+        progress = kwarg.get("progress")
+        if progress is not None:
+            del kwarg["progress"]
+        self.__super_init(**kwarg)
+        self._debug = debug
+        self._progress = progress
+
+    def _makeResult(self):
+        return ImmediateTestResult(self.stream, self.descriptions,
+                                   self.verbosity, debug=self._debug,
+                                   count=self._count, progress=self._progress)
+
+    def run(self, test):
+        self._count = test.countTestCases()
+        return unittest.TextTestRunner.run(self, test)
+
+# setup list of directories to put on the path
+class PathInit:
+    def __init__(self, build, build_inplace, libdir=None):
+        self.inplace = None
+        # Figure out if we should test in-place or test in-build.  If the -b
+        # or -B option was given, test in the place we were told to build in.
+        # Otherwise, we'll look for a build directory and if we find one,
+        # we'll test there, otherwise we'll test in-place.
+        if build:
+            self.inplace = build_inplace
+        if self.inplace is None:
+            # Need to figure it out
+            if os.path.isdir(os.path.join("build", "lib.%s" % PLAT_SPEC)):
+                self.inplace = 0
+            else:
+                self.inplace = 1
+        # Calculate which directories we're going to add to sys.path, and cd
+        # to the appropriate working directory
+        org_cwd = os.getcwd()
+        if self.inplace:
+            self.libdir = "src"
+        else:
+            self.libdir = "lib.%s" % PLAT_SPEC
+            os.chdir("build")
+        # Hack sys.path
+        self.cwd = os.getcwd()
+        sys.path.insert(0, os.path.join(self.cwd, self.libdir))
+        # Hack again for external products.
+        global functional
+        kind = functional and "functional" or "unit"
+        if libdir:
+            extra = os.path.join(org_cwd, libdir)
+            print "Running %s tests from %s" % (kind, extra)
+            self.libdir = extra
+            sys.path.insert(0, extra)
+        else:
+            print "Running %s tests from %s" % (kind, self.cwd)
+        # Make sure functional tests find ftesting.zcml
+        if functional:
+            config_file = 'ftesting.zcml'
+            if not self.inplace:
+                # We chdired into build, so ftesting.zcml is in the
+                # parent directory
+                config_file = os.path.join('..', 'ftesting.zcml')
+            print "Parsing %s" % config_file
+            from zope.testing.functional import FunctionalTestSetup
+            FunctionalTestSetup(config_file)
+
+def match(rx, s):
+    if not rx:
+        return 1
+    if rx[0] == "!":
+        return re.search(rx[1:], s) is None
+    else:
+        return re.search(rx, s) is not None
+
+class TestFileFinder:
+    def __init__(self, prefix):
+        self.files = []
+        self._plen = len(prefix)
+        if not prefix.endswith(os.sep):
+            self._plen += 1
+        global functional
+        if functional:
+            self.dirname = "ftest"
+        else:
+            self.dirname = "test"
+
+    def visit(self, rx, dir, files):
+        if os.path.split(dir)[1] != self.dirname:
+            return
+        # ignore tests that aren't in packages
+        if not "__init__.py" in files:
+            if not files or files == ["CVS"]:
+                return
+            print "not a package", dir
+            return
+
+        # Put matching files in matches.  If matches is non-empty,
+        # then make sure that the package is importable.
+        matches = []
+        for file in files:
+            if file.startswith('test') and os.path.splitext(file)[-1] == '.py':
+                path = os.path.join(dir, file)
+                if match(rx, path):
+                    matches.append(path)
+
+        # ignore tests when the package can't be imported, possibly due to
+        # dependency failures.
+        pkg = dir[self._plen:].replace(os.sep, '.')
+        try:
+            __import__(pkg)
+        # We specifically do not want to catch ImportError since that's useful
+        # information to know when running the tests.
+        except RuntimeError, e:
+            if VERBOSE:
+                print "skipping %s because: %s" % (pkg, e)
+            return
+        else:
+            self.files.extend(matches)
+
+    def module_from_path(self, path):
+        """Return the Python package name indicated by the filesystem path."""
+        assert path.endswith(".py")
+        path = path[self._plen:-3]
+        mod = path.replace(os.sep, ".")
+        return mod
+
+def walk_with_symlinks(top, func, arg):
+    """Like os.path.walk, but follows symlinks on POSIX systems.
+
+    This could theoreticaly result in an infinite loop, if you create symlink
+    cycles in your Zope sandbox, so don't do that.
+    """
+    try:
+        names = os.listdir(top)
+    except os.error:
+        return
+    func(arg, top, names)
+    exceptions = ('.', '..')
+    for name in names:
+        if name not in exceptions:
+            name = os.path.join(top, name)
+            if os.path.isdir(name):
+                walk_with_symlinks(name, func, arg)
+
+
+def check_test_dir():
+    global test_dir
+    if test_dir and not os.path.exists(test_dir):
+        d = pathinit.libdir
+        d = os.path.join(d, test_dir)
+        if os.path.exists(d):
+            if not os.path.isdir(d):
+                raise ValueError(
+                    "%s does not exist and %s is not a directory"
+                    % (test_dir, d)
+                    )
+            test_dir = d
+        else:
+            raise ValueError("%s does not exist!" % test_dir)
+
+
+def find_tests(rx):
+    global finder
+    finder = TestFileFinder(pathinit.libdir)
+
+    check_test_dir()
+    walkdir = test_dir or pathinit.libdir
+    walk_with_symlinks(walkdir, finder.visit, rx)
+    return finder.files
+
+def package_import(modname):
+    mod = __import__(modname)
+    for part in modname.split(".")[1:]:
+        mod = getattr(mod, part)
+    return mod
+
+def get_suite(file):
+    modname = finder.module_from_path(file)
+    try:
+        mod = package_import(modname)
+    except ImportError, err:
+        # print traceback
+        print "Error importing %s\n%s" % (modname, err)
+        traceback.print_exc()
+        if debug:
+            raise
+        return None
+    try:
+        suite_func = mod.test_suite
+    except AttributeError:
+        print "No test_suite() in %s" % file
+        return None
+    return suite_func()
+
+def filter_testcases(s, rx):
+    new = unittest.TestSuite()
+    for test in s._tests:
+        # See if the levels match
+        dolevel = (level == 0) or level >= getattr(test, "level", 0)
+        if not dolevel:
+            continue
+        if isinstance(test, unittest.TestCase):
+            name = test.id() # Full test name: package.module.class.method
+            name = name[1 + name.rfind("."):] # extract method name
+            if not rx or match(rx, name):
+                new.addTest(test)
+        else:
+            filtered = filter_testcases(test, rx)
+            if filtered:
+                new.addTest(filtered)
+    return new
+
+def gui_runner(files, test_filter):
+    if build_inplace:
+        utildir = os.path.join(os.getcwd(), "utilities")
+    else:
+        utildir = os.path.join(os.getcwd(), "..", "utilities")
+    sys.path.append(utildir)
+    import unittestgui
+    suites = []
+    for file in files:
+        suites.append(finder.module_from_path(file) + ".test_suite")
+
+    suites = ", ".join(suites)
+    minimal = (GUI == "minimal")
+    # unittestgui apparently doesn't take the minimal flag anymore
+    unittestgui.main(suites)
+
+class TrackRefs:
+    """Object to track reference counts across test runs."""
+
+    def __init__(self):
+        self.type2count = {}
+        self.type2all = {}
+
+    def update(self):
+        obs = sys.getobjects(0)
+        type2count = {}
+        type2all = {}
+        for o in obs:
+            all = sys.getrefcount(o)
+            t = type(o)
+            if t in type2count:
+                type2count[t] += 1
+                type2all[t] += all
+            else:
+                type2count[t] = 1
+                type2all[t] = all
+
+        ct = [(type2count[t] - self.type2count.get(t, 0),
+               type2all[t] - self.type2all.get(t, 0),
+               t)
+              for t in type2count.iterkeys()]
+        ct.sort()
+        ct.reverse()
+        for delta1, delta2, t in ct:
+            if delta1 or delta2:
+                print "%-55s %8d %8d" % (t, delta1, delta2)
+
+        self.type2count = type2count
+        self.type2all = type2all
+
+def runner(files, test_filter, debug):
+    runner = ImmediateTestRunner(verbosity=VERBOSE, debug=debug,
+        progress=progress)
+    suite = unittest.TestSuite()
+    for file in files:
+        s = get_suite(file)
+        # See if the levels match
+        dolevel = (level == 0) or level >= getattr(s, "level", 0)
+        if s is not None and dolevel:
+            s = filter_testcases(s, test_filter)
+            suite.addTest(s)
+    try:
+        r = runner.run(suite)
+        if timesfn:
+            r.print_times(open(timesfn, "w"))
+            if VERBOSE:
+                print "Wrote timing data to", timesfn
+        if timetests:
+            r.print_times(sys.stdout, timetests)
+    except:
+        if debugger:
+            print "%s:" % (sys.exc_info()[0], )
+            print sys.exc_info()[1]
+            pdb.post_mortem(sys.exc_info()[2])
+        else:
+            raise
+
+def remove_stale_bytecode(arg, dirname, names):
+    names = map(os.path.normcase, names)
+    for name in names:
+        if name.endswith(".pyc") or name.endswith(".pyo"):
+            srcname = name[:-1]
+            if srcname not in names:
+                fullname = os.path.join(dirname, name)
+                print "Removing stale bytecode file", fullname
+                os.unlink(fullname)
+
+def main(module_filter, test_filter, libdir):
+    if not keepStaleBytecode:
+        os.path.walk(os.curdir, remove_stale_bytecode, None)
+
+    # Get the log.ini file from the current directory instead of possibly
+    # buried in the build directory.  XXX This isn't perfect because if
+    # log.ini specifies a log file, it'll be relative to the build directory.
+    # Hmm...
+    logini = os.path.abspath("log.ini")
+
+    from setup import check_manifest
+    check_manifest()
+
+    # Initialize the path and cwd
+    global pathinit
+    pathinit = PathInit(build, build_inplace, libdir)
+
+# No logging module in py 2.1
+#    # Initialize the logging module.
+
+#    import logging.config
+#    logging.basicConfig()
+
+#    level = os.getenv("LOGGING")
+#    if level:
+#        level = int(level)
+#    else:
+#        level = logging.CRITICAL
+#    logging.root.setLevel(level)
+
+#    if os.path.exists(logini):
+#        logging.config.fileConfig(logini)
+
+    files = find_tests(module_filter)
+    files.sort()
+
+    if GUI:
+        gui_runner(files, test_filter)
+    elif LOOP:
+        if REFCOUNT:
+            rc = sys.gettotalrefcount()
+            track = TrackRefs()
+        while 1:
+            runner(files, test_filter, debug)
+            gc.collect()
+            if gc.garbage:
+                print "GARBAGE:", len(gc.garbage), gc.garbage
+                return
+            if REFCOUNT:
+                prev = rc
+                rc = sys.gettotalrefcount()
+                print "totalrefcount=%-8d change=%-6d" % (rc, rc - prev)
+                track.update()
+    else:
+        runner(files, test_filter, debug)
+
+
+def process_args(argv=None):
+    import getopt
+    global module_filter
+    global test_filter
+    global VERBOSE
+    global LOOP
+    global GUI
+    global TRACE
+    global REFCOUNT
+    global debug
+    global debugger
+    global build
+    global level
+    global libdir
+    global timesfn
+    global timetests
+    global progress
+    global build_inplace
+    global keepStaleBytecode
+    global functional
+    global test_dir
+
+    if argv is None:
+        argv = sys.argv
+
+    module_filter = None
+    test_filter = None
+    VERBOSE = 2
+    LOOP = 0
+    GUI = 0
+    TRACE = 0
+    REFCOUNT = 0
+    debug = 0 # Don't collect test results; simply let tests crash
+    debugger = 0
+    build = 0
+    build_inplace = 0
+    gcthresh = None
+    gcdebug = 0
+    gcflags = []
+    level = 1
+    libdir = '.'
+    progress = 0
+    timesfn = None
+    timetests = 0
+    keepStaleBytecode = 0
+    functional = 0
+    test_dir = None
+
+    try:
+        opts, args = getopt.getopt(argv[1:], "a:bBcdDfg:G:hLmprtTuv",
+                                   ["all", "help", "libdir=", "times=",
+                                    "keepbytecode", "dir=", "build"])
+    except getopt.error, msg:
+        print msg
+        print "Try `python %s -h' for more information." % argv[0]
+        sys.exit(2)
+
+    for k, v in opts:
+        if k == "-a":
+            level = int(v)
+        elif k == "--all":
+            level = 0
+            os.environ["COMPLAIN_IF_TESTS_MISSED"]='1'
+        elif k in ("-b", "--build"):
+            build = 1
+        elif k == "-B":
+             build = build_inplace = 1
+        elif k == "-c":
+            # make sure you have a recent version of pychecker
+            if not os.environ.get("PYCHECKER"):
+                os.environ["PYCHECKER"] = "-q"
+            import pychecker.checker
+        elif k == "-d":
+            debug = 1
+        elif k == "-D":
+            debug = 1
+            debugger = 1
+        elif k == "-f":
+            functional = 1
+        elif k in ("-h", "--help"):
+            print __doc__
+            sys.exit(0)
+        elif k == "-g":
+            gcthresh = int(v)
+        elif k == "-G":
+            if not v.startswith("DEBUG_"):
+                print "-G argument must be DEBUG_ flag, not", repr(v)
+                sys.exit(1)
+            gcflags.append(v)
+        elif k == '--keepbytecode':
+            keepStaleBytecode = 1
+        elif k == '--libdir':
+            libdir = v
+        elif k == "-L":
+            LOOP = 1
+        elif k == "-m":
+            GUI = "minimal"
+        elif k == "-p":
+            progress = 1
+        elif k == "-r":
+            if hasattr(sys, "gettotalrefcount"):
+                REFCOUNT = 1
+            else:
+                print "-r ignored, because it needs a debug build of Python"
+        elif k == "-T":
+            TRACE = 1
+        elif k == "-t":
+            if not timetests:
+                timetests = 50
+        elif k == "-u":
+            GUI = 1
+        elif k == "-v":
+            VERBOSE += 1
+        elif k == "--times":
+            try:
+                timetests = int(v)
+            except ValueError:
+                # must be a filename to write
+                timesfn = v
+        elif k == '--dir':
+            test_dir = v
+
+    if gcthresh is not None:
+        if gcthresh == 0:
+            gc.disable()
+            print "gc disabled"
+        else:
+            gc.set_threshold(gcthresh)
+            print "gc threshold:", gc.get_threshold()
+
+    if gcflags:
+        val = 0
+        for flag in gcflags:
+            v = getattr(gc, flag, None)
+            if v is None:
+                print "Unknown gc flag", repr(flag)
+                print gc.set_debug.__doc__
+                sys.exit(1)
+            val |= v
+        gcdebug |= v
+
+    if gcdebug:
+        gc.set_debug(gcdebug)
+
+    if build:
+        # Python 2.3 is more sane in its non -q output
+        if sys.hexversion >= 0x02030000:
+            qflag = ""
+        else:
+            qflag = "-q"
+        cmd = sys.executable + " setup.py " + qflag + " build"
+        if build_inplace:
+            cmd += "_ext -i"
+        if VERBOSE:
+            print cmd
+        sts = os.system(cmd)
+        if sts:
+            print "Build failed", hex(sts)
+            sys.exit(1)
+
+    if VERBOSE:
+        kind = functional and "functional" or "unit"
+        if level == 0:
+            print "Running %s tests at all levels" % kind
+        else:
+            print "Running %s tests at level %d" % (kind, level)
+
+    # XXX We want to change *visible* warnings into errors.  The next
+    # line changes all warnings into errors, including warnings we
+    # normally never see.  In particular, test_datetime does some
+    # short-integer arithmetic that overflows to long ints, and, by
+    # default, Python doesn't display the overflow warning that can
+    # be enabled when this happens.  The next line turns that into an
+    # error instead.  Guido suggests that a better to get what we're
+    # after is to replace warnings.showwarning() with our own thing
+    # that raises an error.
+##    warnings.filterwarnings("error")
+    warnings.filterwarnings("ignore", module="logging")
+
+    if args:
+        if len(args) > 1:
+            test_filter = args[1]
+        module_filter = args[0]
+    try:
+        if TRACE:
+            # if the trace module is used, then we don't exit with
+            # status if on a false return value from main.
+            coverdir = os.path.join(os.getcwd(), "coverage")
+            import trace
+            ignoremods = ["os", "posixpath", "stat"]
+            tracer = trace.Trace(ignoredirs=[sys.prefix, sys.exec_prefix],
+                                 ignoremods=ignoremods,
+                                 trace=0, count=1)
+
+            tracer.runctx("main(module_filter, test_filter, libdir)",
+                          globals=globals(), locals=vars())
+            r = tracer.results()
+            path = "/tmp/trace.%s" % os.getpid()
+            import cPickle
+            f = open(path, "wb")
+            cPickle.dump(r, f)
+            f.close()
+            print path
+            r.write_results(show_missing=1, summary=1, coverdir=coverdir)
+        else:
+            bad = main(module_filter, test_filter, libdir)
+            if bad:
+                sys.exit(1)
+    except ImportError, err:
+        print err
+        print sys.path
+        raise
+
+
+if __name__ == "__main__":
+    process_args()

Added: tracker/vendor/roundup/current/scripts/README.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/scripts/README.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,29 @@
+Scripts in this directory:
+
+add-issue
+ Add a single issue, as specified on the command line, to your tracker. The
+ initial message for the issue is taken from standard input.
+
+roundup-reminder
+ Generate an email that lists outstanding issues. Send in both plain text
+ and HTML formats.
+
+schema_diagram.py
+ Generate a schema diagram for a roundup tracker. It generates a 'dot file'
+ that is then fed into the 'dot' tool (http://www.graphviz.org) to generate
+ a graph.
+
+server-ctl
+ Control the roundup-server daemon from the command line with start, stop,
+ restart, condstart (conditional start - only if server is stopped) and
+ status commands.
+
+roundup.rc-debian
+ An control script that may be installed in /etc/init.d on Debian systems.
+ Offers start, stop and restart commands and integrates with the Debian
+ init process.
+
+imapServer.py
+ This IMAP server script that runs in the background and checks for new
+ email from a variety of mailboxes.
+

Added: tracker/vendor/roundup/current/scripts/add-issue
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/scripts/add-issue	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,60 @@
+#! /usr/bin/env python
+# $Id: add-issue,v 1.2 2003/04/30 01:28:37 richard Exp $
+
+'''
+Usage: %s <tracker home> <priority> <issue title>
+
+Create a new issue in the given tracker. Input is taken from STDIN to
+create the initial issue message (which may be empty). Issues will be
+created as the current user (%s) if they exist as a Roundup
+user, or "admin" otherwise.
+'''
+
+import sys, os, pwd
+
+from roundup import instance, mailgw, date
+
+# open the instance
+username = pwd.getpwuid(os.getuid())[0]
+if len(sys.argv) < 3:
+    print "Error: Not enough arguments"
+    print __doc__.strip()%(sys.argv[0], username)
+    sys.exit(1)
+tracker_home = sys.argv[1]
+issue_priority = sys.argv[2]
+issue_title = ' '.join(sys.argv[3:])
+
+# get the message, if any
+message_text = sys.stdin.read().strip()
+
+# open the tracker
+tracker = instance.open(tracker_home)
+db = tracker.open('admin')
+uid = db.user.lookup('admin')
+try:
+    # try to open the tracker as the current user
+    uid = db.user.lookup(username)
+    db.close()
+    db = tracker.open(username)
+except KeyError:
+    pass
+
+try:
+
+    # handle the message
+    messages = []
+    if message_text:
+        summary, x = mailgw.parseContent(message_text, 0, 0)
+        msg = db.msg.create(content=message_text, summary=summary, author=uid,
+            date=date.Date())
+        messages = [msg]
+
+    # now create the issue
+    db.issue.create(title=issue_title, priority=issue_priority,
+        messages=messages)
+
+    db.commit()
+finally:
+    db.close()
+
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/scripts/copy-user.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/scripts/copy-user.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,87 @@
+#!/usr/bin/env python
+# Copyright (C) 2003 by Intevation GmbH
+# Author:
+# Thomas Arendsen Hein <thomas at intevation.de>
+#
+# This program is free software dual licensed under the GPL (>=v2)
+# and the Roundup Licensing (see COPYING.txt in the roundup distribution).
+
+"""
+copy-user <instance-home> <instance-home> <userid> [<userid>...]
+
+Copy one or more Roundup users from one tracker instance to another.
+Example:
+    copy-user /roundup/tracker1 /roundup/tracker2 `seq 3 10` 14 16
+    (copies users 3, 4, 5, 6, 7, 8, 9, 10, 14 and 16)
+"""
+
+__version__ = "$Revision: 1.1 $"
+# $Source: /cvsroot/roundup/roundup/scripts/copy-user.py,v $
+# $Id: copy-user.py,v 1.1 2003/12/04 23:13:43 richard Exp $
+
+import sys
+import roundup.instance
+
+
+def copy_user(home1, home2, *userids):
+    """Copy users which are listed by userids from home1 to home2"""
+
+    copyattribs = ['username', 'password', 'address', 'realname', 'phone',
+                   'organisation', 'alternate_addresses', 'roles', 'timezone']
+
+    try:
+        instance1 = roundup.instance.open(home1)
+        print "Opened source instance: %s" % home1
+    except:
+        print "Can't open source instance: %s" % home1
+        sys.exit(1)
+
+    try:
+        instance2 = roundup.instance.open(home2)
+        print "Opened target instance: %s" % home2
+    except:
+        print "Can't open target instance: %s" % home2
+        sys.exit(1)
+
+    db1 = instance1.open('admin')
+    db2 = instance2.open('admin')
+
+    userlist = db1.user.list()
+    for userid in userids:
+        try:
+            userid = str(int(userid))
+        except ValueError, why:
+            print "Not a numeric user id: %s  Skipping ..." % (userid,)
+            continue
+        if userid not in userlist:
+            print "User %s not in source instance. Skipping ..." % userid
+            continue
+
+        user = {}
+        for attrib in copyattribs:
+            value = db1.user.get(userid, attrib)
+            if value:
+                user[attrib] = value
+        try:
+            db2.user.lookup(user['username'])
+            print "User %s: Username '%s' exists in target instance. Skipping ..." % (userid, user['username'])
+            continue
+        except KeyError, why:
+            pass
+        print "Copying user %s (%s) ..." % (userid, user['username'])
+        db2.user.create(**user)
+
+    db2.commit()
+    db2.close()
+    print "Closed target instance."
+    db1.close()
+    print "Closed source instance."
+
+
+if __name__ == "__main__":
+    if len(sys.argv) < 4:
+        print __doc__
+        sys.exit(1)
+    else:
+        copy_user(*sys.argv[1:])
+

Added: tracker/vendor/roundup/current/scripts/hyperdb_example.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/scripts/hyperdb_example.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,19 @@
+from roundup.hyperdb import String, Number, Multilink
+from roundup.backends.back_bsddb import Database, Class
+
+class config:
+    DATABASE='/tmp/hyperdb_example'
+
+db = Database(config, 'admin')
+spam = Class(db, 'spam', name=String(), size=Number())
+widget = Class(db, 'widget', title=String(), spam=Multilink('spam'))
+
+oneid = spam.create(name='one', size=1)
+twoid = spam.create(name='two', size=2)
+
+widgetid = widget.create(title='a widget', spam=[oneid, twoid])
+
+# dumb, simple query
+print widget.find(spam=oneid)
+print widget.history(widgetid)
+print widget.search_text(

Added: tracker/vendor/roundup/current/scripts/imapServer.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/scripts/imapServer.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,383 @@
+#!/usr/bin/env python
+"""\
+This script is a wrapper around the mailgw.py script that exists in roundup.
+It runs as service instead of running as a one-time shot.
+It also connects to a secure IMAP server. The main reasons for this script are:
+
+1) The roundup-mailgw script isn't designed to run as a server. It
+    expects that you either run it by hand, and enter the password each
+    time, or you supply the password on the command line. I prefer to
+    run a server that I initialize with the password, and then it just
+    runs. I don't want to have to pass it on the command line, so
+    running through crontab isn't a possibility. (This wouldn't be a
+    problem on a local machine running through a mailspool.)
+2) mailgw.py somehow screws up SSL support so IMAP4_SSL doesn't work. So
+    hopefully running that work outside of the mailgw will allow it to work.
+3) I wanted to be able to check multiple projects at the same time.
+    roundup-mailgw is only for 1 mailbox and 1 project.
+
+
+*TODO*:
+  For the first round, the program spawns a new roundup-mailgw for
+  each imap message that it finds and pipes the result in. In the
+  future it might be more practical to actually include the roundup
+  files and run the appropriate commands using python.
+
+*TODO*:
+  Look into supporting a logfile instead of using 2>/logfile
+
+*TODO*:
+  Add an option for changing the uid/gid of the running process.
+"""
+
+import getpass
+import logging
+import imaplib
+import optparse
+import os
+import re
+import time
+
+logging.basicConfig()
+log = logging.getLogger('IMAPServer')
+
+version = '0.1.2'
+
+class RoundupMailbox:
+    """This contains all the info about each mailbox.
+    Username, Password, server, security, roundup database
+    """
+    def __init__(self, dbhome='', username=None, password=None, mailbox=None
+        , server=None, protocol='imaps'):
+        self.username = username
+        self.password = password
+        self.mailbox = mailbox
+        self.server = server
+        self.protocol = protocol
+        self.dbhome = dbhome
+
+        try:
+            if not self.dbhome:
+                self.dbhome = raw_input('Tracker home: ')
+                if not os.path.exists(self.dbhome):
+                    raise ValueError, 'Invalid home address: ' \
+                        'directory "%s" does not exist.' % self.dbhome
+
+            if not self.server:
+                self.server = raw_input('Server: ')
+                if not self.server:
+                    raise ValueError, 'No Servername supplied'
+                protocol = raw_input('protocol [imaps]? ')
+                self.protocol = protocol
+
+            if not self.username:
+                self.username = raw_input('Username: ')
+                if not self.username:
+                    raise ValueError, 'Invalid Username'
+
+            if not self.password:
+                print 'For server %s, user %s' % (self.server, self.username)
+                self.password = getpass.getpass()
+                # password can be empty because it could be superceeded
+                # by a later entry
+
+            #if self.mailbox is None:
+            #   self.mailbox = raw_input('Mailbox [INBOX]: ')
+            #   # We allow an empty mailbox because that will
+            #   # select the INBOX, whatever it is called
+
+        except (KeyboardInterrupt, EOFError):
+            raise ValueError, 'Canceled by User'
+
+    def __str__(self):
+        return 'Mailbox{ server:%(server)s, protocol:%(protocol)s, ' \
+            'username:%(username)s, mailbox:%(mailbox)s, ' \
+            'dbhome:%(dbhome)s }' % self.__dict__
+
+
+# [als] class name is misleading.  this is imap client, not imap server
+class IMAPServer:
+
+    """IMAP mail gatherer.
+
+    This class runs as a server process. It is configured with a list of
+    mailboxes to connect to, along with the roundup database directories
+    that correspond with each email address.  It then connects to each
+    mailbox at a specified interval, and if there are new messages it
+    reads them, and sends the result to the roundup.mailgw.
+
+    *TODO*:
+      Try to be smart about how you access the mailboxes so that you can
+      connect once, and access multiple mailboxes and possibly multiple
+      usernames.
+
+    *NOTE*:
+      This assumes that if you are using the same user on the same
+      server, you are using the same password. (the last one supplied is
+      used.) Empty passwords are ignored.  Only the last protocol
+      supplied is used.
+    """
+
+    def __init__(self, pidfile=None, delay=5, daemon=False):
+        #This is sorted by servername, then username, then mailboxes
+        self.mailboxes = {}
+        self.delay = float(delay)
+        self.pidfile = pidfile
+        self.daemon = daemon
+
+    def setDelay(self, delay):
+        self.delay = delay
+
+    def addMailbox(self, mailbox):
+        """ The linkage is as follows:
+        servers -- users - mailbox:dbhome
+        So there can be multiple servers, each with multiple users.
+        Each username can be associated with multiple mailboxes.
+        each mailbox is associated with 1 database home
+        """
+        log.info('Adding mailbox %s', mailbox)
+        if not self.mailboxes.has_key(mailbox.server):
+            self.mailboxes[mailbox.server] = {'protocol':'imaps', 'users':{}}
+        server = self.mailboxes[mailbox.server]
+        if mailbox.protocol:
+            server['protocol'] = mailbox.protocol
+
+        if not server['users'].has_key(mailbox.username):
+            server['users'][mailbox.username] = {'password':'', 'mailboxes':{}}
+        user = server['users'][mailbox.username]
+        if mailbox.password:
+            user['password'] = mailbox.password
+
+        if user['mailboxes'].has_key(mailbox.mailbox):
+            raise ValueError, 'Mailbox is already defined'
+
+        user['mailboxes'][mailbox.mailbox] = mailbox.dbhome
+
+    def _process(self, message, dbhome):
+        """Actually process one of the email messages"""
+        child = os.popen('roundup-mailgw %s' % dbhome, 'wb')
+        child.write(message)
+        child.close()
+        #print message
+
+    def _getMessages(self, serv, count, dbhome):
+        """This assumes that you currently have a mailbox open, and want to
+        process all messages that are inside.
+        """
+        for n in range(1, count+1):
+            (t, data) = serv.fetch(n, '(RFC822)')
+            if t == 'OK':
+                self._process(data[0][1], dbhome)
+                serv.store(n, '+FLAGS', r'(\Deleted)')
+
+    def checkBoxes(self):
+        """This actually goes out and does all the checking.
+        Returns False if there were any errors, otherwise returns true.
+        """
+        noErrors = True
+        for server in self.mailboxes:
+            log.info('Connecting to server: %s', server)
+            s_vals = self.mailboxes[server]
+
+            try:
+                for user in s_vals['users']:
+                    u_vals = s_vals['users'][user]
+                    # TODO: As near as I can tell, you can only
+                    # login with 1 username for each connection to a server.
+                    protocol = s_vals['protocol'].lower()
+                    if protocol == 'imaps':
+                        serv = imaplib.IMAP4_SSL(server)
+                    elif protocol == 'imap':
+                        serv = imaplib.IMAP4(server)
+                    else:
+                        raise ValueError, 'Unknown protocol %s' % protocol
+
+                    password = u_vals['password']
+
+                    try:
+                        log.info('Connecting as user: %s', user)
+                        serv.login(user, password)
+
+                        for mbox in u_vals['mailboxes']:
+                            dbhome = u_vals['mailboxes'][mbox]
+                            log.info('Using mailbox: %s, home: %s',
+                                mbox, dbhome)
+                            #access a specific mailbox
+                            if mbox:
+                                (t, data) = serv.select(mbox)
+                            else:
+                                # Select the default mailbox (INBOX)
+                                (t, data) = serv.select()
+                            try:
+                                nMessages = int(data[0])
+                            except ValueError:
+                                nMessages = 0
+
+                            log.info('Found %s messages', nMessages)
+
+                            if nMessages:
+                                self._getMessages(serv, nMessages, dbhome)
+                                serv.expunge()
+
+                            # We are done with this mailbox
+                            serv.close()
+                    except:
+                        log.exception('Exception with server %s user %s',
+                            server, user)
+                        noErrors = False
+
+                    serv.logout()
+                    serv.shutdown()
+                    del serv
+            except:
+                log.exception('Exception while connecting to %s', server)
+                noErrors = False
+        return noErrors
+
+
+    def makeDaemon(self):
+        """Turn this process into a daemon.
+
+        - make our parent PID 1
+
+        Write our new PID to the pidfile.
+
+        From A.M. Kuuchling (possibly originally Greg Ward) with
+        modification from Oren Tirosh, and finally a small mod from me.
+        Originally taken from roundup.scripts.roundup_server.py
+        """
+        log.info('Running as Daemon')
+        # Fork once
+        if os.fork() != 0:
+            os._exit(0)
+
+        # Create new session
+        os.setsid()
+
+        # Second fork to force PPID=1
+        pid = os.fork()
+        if pid:
+            if self.pidfile:
+                pidfile = open(self.pidfile, 'w')
+                pidfile.write(str(pid))
+                pidfile.close()
+            os._exit(0)
+
+    def run(self):
+        """Run email gathering daemon.
+
+        This spawns itself as a daemon, and then runs continually, just
+        sleeping inbetween checks.  It is recommended that you run
+        checkBoxes once first before you select run. That way you can
+        know if there were any failures.
+        """
+        if self.daemon:
+            self.makeDaemon()
+        while True:
+
+            time.sleep(self.delay * 60.0)
+            log.info('Time: %s', time.strftime('%Y-%m-%d %H:%M:%S'))
+            self.checkBoxes()
+
+def getItems(s):
+    """Parse a string looking for userame at server"""
+    myRE = re.compile(
+        r'((?P<protocol>[^:]+)://)?'#You can supply a protocol if you like
+        r'('                        #The username part is optional
+         r'(?P<username>[^:]+)'     #You can supply the password as
+         r'(:(?P<password>.+))?'    #username:password at server
+        r'@)?'
+        r'(?P<server>[^/]+)'
+        r'(/(?P<mailbox>.+))?$'
+    )
+    m = myRE.match(s)
+    if m:
+        return m.groupdict()
+    else:
+        return None
+
+def main():
+    """This is what is called if run at the prompt"""
+    parser = optparse.OptionParser(
+        version=('%prog ' + version),
+        usage="""usage: %prog [options] (home server)...
+
+So each entry has a home, and then the server configuration. Home is just
+a path to the roundup issue tracker. The server is something of the form:
+
+    imaps://user:password@server/mailbox
+
+If you don't supply the protocol, imaps is assumed. Without user or
+password, you will be prompted for them. The server must be supplied.
+Without mailbox the INBOX is used.
+
+Examples:
+  %prog /home/roundup/trackers/test imaps://test@imap.example.com/test
+  %prog /home/roundup/trackers/test imap.example.com \
+/home/roundup/trackers/test2 imap.example.com/test2
+"""
+    )
+    parser.add_option('-d', '--delay', dest='delay', type='float',
+        metavar='<sec>', default=5,
+        help="Set the delay between checks in minutes. (default 5)"
+    )
+    parser.add_option('-p', '--pid-file', dest='pidfile',
+        metavar='<file>', default=None,
+        help="The pid of the server process will be written to <file>"
+    )
+    parser.add_option('-n', '--no-daemon', dest='daemon',
+        action='store_false', default=True,
+        help="Do not fork into the background after running the first check."
+    )
+    parser.add_option('-v', '--verbose', dest='verbose',
+        action='store_const', const=logging.INFO,
+        help="Be more verbose in letting you know what is going on."
+        " Enables informational messages."
+    )
+    parser.add_option('-V', '--very-verbose', dest='verbose',
+        action='store_const', const=logging.DEBUG,
+        help="Be very verbose in letting you know what is going on."
+            " Enables debugging messages."
+    )
+    parser.add_option('-q', '--quiet', dest='verbose',
+        action='store_const', const=logging.ERROR,
+        help="Be less verbose. Ignores warnings, only prints errors."
+    )
+    parser.add_option('-Q', '--very-quiet', dest='verbose',
+        action='store_const', const=logging.CRITICAL,
+        help="Be much less verbose. Ignores warnings and errors."
+            " Only print CRITICAL messages."
+    )
+
+    (opts, args) = parser.parse_args()
+    if (len(args) == 0) or (len(args) % 2 == 1):
+        parser.error('Invalid number of arguments. '
+            'Each site needs a home and a server.')
+
+    log.setLevel(opts.verbose)
+    myServer = IMAPServer(delay=opts.delay, pidfile=opts.pidfile,
+        daemon=opts.daemon)
+    for i in range(0,len(args),2):
+        home = args[i]
+        server = args[i+1]
+        if not os.path.exists(home):
+            parser.error('Home: "%s" does not exist' % home)
+
+        info = getItems(server)
+        if not info:
+            parser.error('Invalid server string: "%s"' % server)
+
+        myServer.addMailbox(
+            RoundupMailbox(dbhome=home, mailbox=info['mailbox']
+            , username=info['username'], password=info['password']
+            , server=info['server'], protocol=info['protocol']
+            )
+        )
+
+    if myServer.checkBoxes():
+        myServer.run()
+
+if __name__ == '__main__':
+    main()
+
+# vim: et ft=python si sts=4 sw=4

Added: tracker/vendor/roundup/current/scripts/import_sf.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/scripts/import_sf.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,402 @@
+''' Import tracker data from Sourceforge.NET
+
+This script needs four steps to work:
+
+1. Export the project XML data using the admin web interface at sf.net
+2. Run the file fetching (these are not included in the XML):
+
+    import_sf.py files <path to XML> <path to files dir>
+
+   this will place all the downloaded files in the files dir by file id.
+3. Convert the sf.net XML to Roundup "export" format:
+
+    import_sf.py import <tracker home> <path to XML> <path to files dir>
+
+   this will generate a directory "/tmp/imported" which contains the
+   data to be imported into a Roundup tracker.
+4. Import the data:
+
+    roundup-admin -i <tracker home> import /tmp/imported
+
+And you're done!
+'''
+
+import sys, sets, os, csv, time, urllib2, httplib, mimetypes, urlparse
+
+try:
+    import cElementTree as ElementTree
+except ImportError:
+    from elementtree import ElementTree
+
+from roundup import instance, hyperdb, date, support, password
+
+today = date.Date('.')
+
+DL_URL = 'http://sourceforge.net/tracker/download.php?group_id=%(group_id)s&atid=%(atid)s&aid=%(aid)s'
+
+def get_url(aid):
+    """ so basically we have to jump through hoops, given an artifact id, to
+    figure what the URL should be to access that artifact, and hence any
+    attached files."""
+    # first we hit this URL...
+    conn = httplib.HTTPConnection("sourceforge.net")
+    conn.request("GET", "/support/tracker.php?aid=%s"%aid)
+    response = conn.getresponse()
+    # which should respond with a redirect to the correct url which has the
+    # magic "group_id" and "atid" values in it that we need
+    assert response.status == 302, 'response code was %s'%response.status
+    location = response.getheader('location')
+    query = urlparse.urlparse(response.getheader('location'))[-2]
+    info = dict([param.split('=') for param in query.split('&')])
+    return DL_URL%info
+
+def fetch_files(xml_file, file_dir):
+    """ Fetch files referenced in the xml_file into the dir file_dir. """
+    root = ElementTree.parse(xml_file).getroot()
+    to_fetch = sets.Set()
+    deleted = sets.Set()
+    for artifact in root.find('artifacts'):
+        for field in artifact.findall('field'):
+            if field.get('name') == 'artifact_id':
+                aid = field.text
+        for field in artifact.findall('field'):
+            if field.get('name') != 'artifact_history': continue
+            for event in field.findall('history'):
+                d = {}
+                for field in event.findall('field'):
+                    d[field.get('name')] = field.text
+                if d['field_name'] == 'File Added':
+                    fid = d['old_value'].split(':')[0]
+                    to_fetch.add((aid, fid))
+                if d['field_name'] == 'File Deleted':
+                    fid = d['old_value'].split(':')[0]
+                    deleted.add((aid, fid))
+    to_fetch = to_fetch - deleted
+
+    got = sets.Set(os.listdir(file_dir))
+    to_fetch = to_fetch - got
+
+    # load cached urls (sigh)
+    urls = {}
+    if os.path.exists(os.path.join(file_dir, 'urls.txt')):
+        for line in open(os.path.join(file_dir, 'urls.txt')):
+            aid, url = line.strip().split()
+            urls[aid] = url
+
+    for aid, fid in support.Progress('Fetching files', list(to_fetch)):
+        if fid in got: continue
+        if not urls.has_key(aid):
+            urls[aid] = get_url(aid)
+            f = open(os.path.join(file_dir, 'urls.txt'), 'a')
+            f.write('%s %s\n'%(aid, urls[aid]))
+            f.close()
+        url = urls[aid] + '&file_id=' + fid
+        f = urllib2.urlopen(url)
+        data = f.read()
+        n = open(os.path.join(file_dir, fid), 'w')
+        n.write(data)
+        f.close()
+        n.close()
+
+def import_xml(tracker_home, xml_file, file_dir):
+    """ Generate Roundup tracker import files based on the tracker schema,
+    sf.net xml export and downloaded files from sf.net. """
+    tracker = instance.open(tracker_home)
+    db = tracker.open('admin')
+
+    resolved = db.status.lookup('resolved')
+    unread = db.status.lookup('unread')
+    chatting = db.status.lookup('unread')
+    critical = db.priority.lookup('critical')
+    urgent = db.priority.lookup('urgent')
+    bug = db.priority.lookup('bug')
+    feature = db.priority.lookup('feature')
+    wish = db.priority.lookup('wish')
+    adminuid = db.user.lookup('admin')
+    anonuid = db.user.lookup('anonymous')
+
+    root = ElementTree.parse(xml_file).getroot()
+
+    def to_date(ts):
+        return date.Date(time.gmtime(float(ts)))
+
+    # parse out the XML
+    artifacts = []
+    categories = sets.Set()
+    users = sets.Set()
+    add_files = sets.Set()
+    remove_files = sets.Set()
+    for artifact in root.find('artifacts'):
+        d = {}
+        op = {}
+        artifacts.append(d)
+        for field in artifact.findall('field'):
+            name = field.get('name')
+            if name == 'artifact_messages':
+                for message in field.findall('message'):
+                    l = d.setdefault('messages', [])
+                    m = {}
+                    l.append(m)
+                    for field in message.findall('field'):
+                        name = field.get('name')
+                        if name == 'adddate':
+                            m[name] = to_date(field.text)
+                        else:
+                            m[name] = field.text
+                        if name == 'user_name': users.add(field.text)
+            elif name == 'artifact_history':
+                for event in field.findall('history'):
+                    l = d.setdefault('history', [])
+                    e = {}
+                    l.append(e)
+                    for field in event.findall('field'):
+                        name = field.get('name')
+                        if name == 'entrydate':
+                            e[name] = to_date(field.text)
+                        else:
+                            e[name] = field.text
+                        if name == 'mod_by': users.add(field.text)
+                    if e['field_name'] == 'File Added':
+                        add_files.add(e['old_value'].split(':')[0])
+                    elif e['field_name'] == 'File Deleted':
+                        remove_files.add(e['old_value'].split(':')[0])
+            elif name == 'details':
+                op['body'] = field.text
+            elif name == 'submitted_by':
+                op['user_name'] = field.text
+                d[name] = field.text
+                users.add(field.text)
+            elif name == 'open_date':
+                thedate = to_date(field.text)
+                op['adddate'] = thedate
+                d[name] = thedate
+            else:
+                d[name] = field.text
+
+        categories.add(d['category'])
+
+        if op.has_key('body'):
+            l = d.setdefault('messages', [])
+            l.insert(0, op)
+
+    add_files -= remove_files
+
+    # create users
+    userd = {'nobody': '2'}
+    users.remove('nobody')
+    data = [
+        {'id': '1', 'username': 'admin', 'password': password.Password('admin'),
+            'roles': 'Admin', 'address': 'richard at python.org'},
+        {'id': '2', 'username': 'anonymous', 'roles': 'Anonymous'},
+    ]
+    for n, user in enumerate(list(users)):
+        userd[user] = n+3
+        data.append({'id': str(n+3), 'username': user, 'roles': 'User',
+            'address': '%s at users.sourceforge.net'%user})
+    write_csv(db.user, data)
+    users=userd
+
+    # create categories
+    categoryd = {'None': None}
+    categories.remove('None')
+    data = []
+    for n, category in enumerate(list(categories)):
+        categoryd[category] = n
+        data.append({'id': str(n), 'name': category})
+    write_csv(db.keyword, data)
+    categories = categoryd
+
+    # create issues
+    issue_data = []
+    file_data = []
+    message_data = []
+    issue_journal = []
+    message_id = 0
+    for artifact in artifacts:
+        d = {}
+        d['id'] = artifact['artifact_id']
+        d['title'] = artifact['summary']
+        d['assignedto'] = users[artifact['assigned_to']]
+        if d['assignedto'] == '2':
+            d['assignedto'] = None
+        d['creation'] = artifact['open_date']
+        activity = artifact['open_date']
+        d['creator'] = users[artifact['submitted_by']]
+        actor = d['creator']
+        if categories[artifact['category']]:
+            d['topic'] = [categories[artifact['category']]]
+        issue_journal.append((
+            d['id'], d['creation'].get_tuple(), d['creator'], "'create'", {}
+        ))
+
+        p = int(artifact['priority'])
+        if artifact['artifact_type'] == 'Feature Requests':
+            if p > 3:
+                d['priority'] = feature
+            else:
+                d['priority'] = wish
+        else:
+            if p > 7:
+                d['priority'] = critical
+            elif p > 5:
+                d['priority'] = urgent
+            elif p > 3:
+                d['priority'] = bug
+            else:
+                d['priority'] = feature
+
+        s = artifact['status']
+        if s == 'Closed':
+            d['status'] = resolved
+        elif s == 'Deleted':
+            d['status'] = resolved
+            d['is retired'] = True
+        else:
+            d['status'] = unread
+
+        nosy = sets.Set()
+        for message in artifact.get('messages', []):
+            authid = users[message['user_name']]
+            if not message['body']: continue
+            body = convert_message(message['body'], message_id)
+            if not body: continue
+            m = {'content': body, 'author': authid,
+                'date': message['adddate'],
+                'creation': message['adddate'], }
+            message_data.append(m)
+            if authid not in (None, '2'):
+                nosy.add(authid)
+            activity = message['adddate']
+            actor = authid
+            if d['status'] == unread:
+                d['status'] = chatting
+
+        # add import message
+        m = {'content': 'IMPORT FROM SOURCEFORGE', 'author': '1',
+            'date': today, 'creation': today}
+        message_data.append(m)
+
+        # sort messages and assign ids
+        d['messages'] = []
+        message_data.sort(lambda a,b:cmp(a['date'],b['date']))
+        for message in message_data:
+            message_id += 1
+            message['id'] = str(message_id)
+            d['messages'].append(message_id)
+
+        d['nosy'] = list(nosy)
+
+        files = []
+        for event in artifact.get('history', []):
+            if event['field_name'] == 'File Added':
+                fid, name = event['old_value'].split(':', 1)
+                if fid in add_files:
+                    files.append(fid)
+                    name = name.strip()
+                    try:
+                        f = open(os.path.join(file_dir, fid))
+                        content = f.read()
+                        f.close()
+                    except:
+                        content = 'content missing'
+                    file_data.append({
+                        'id': fid,
+                        'creation': event['entrydate'],
+                        'creator': users[event['mod_by']],
+                        'name': name,
+                        'type': mimetypes.guess_type(name)[0],
+                        'content': content,
+                    })
+                continue
+            elif event['field_name'] == 'close_date':
+                action = "'set'"
+                info = { 'status': unread }
+            elif event['field_name'] == 'summary':
+                action = "'set'"
+                info = { 'title': event['old_value'] }
+            else:
+                # not an interesting / translatable event
+                continue
+            row = [ d['id'], event['entrydate'].get_tuple(),
+                users[event['mod_by']], action, info ]
+            if event['entrydate'] > activity:
+                activity = event['entrydate']
+            issue_journal.append(row)
+        d['files'] = files
+
+        d['activity'] = activity
+        d['actor'] = actor
+        issue_data.append(d)
+
+    write_csv(db.issue, issue_data)
+    write_csv(db.msg, message_data)
+    write_csv(db.file, file_data)
+
+    f = open('/tmp/imported/issue-journals.csv', 'w')
+    writer = csv.writer(f, colon_separated)
+    writer.writerows(issue_journal)
+    f.close()
+
+def convert_message(content, id):
+    ''' Strip off the useless sf message header crap '''
+    if content[:14] == 'Logged In: YES':
+        return '\n'.join(content.splitlines()[3:]).strip()
+    return content
+
+class colon_separated(csv.excel):
+    delimiter = ':'
+
+def write_csv(klass, data):
+    props = klass.getprops()
+    if not os.path.exists('/tmp/imported'):
+        os.mkdir('/tmp/imported')
+    f = open('/tmp/imported/%s.csv'%klass.classname, 'w')
+    writer = csv.writer(f, colon_separated)
+    propnames = klass.export_propnames()
+    propnames.append('is retired')
+    writer.writerow(propnames)
+    for entry in data:
+        row = []
+        for name in propnames:
+            if name == 'is retired':
+                continue
+            prop = props[name]
+            if entry.has_key(name):
+                if isinstance(prop, hyperdb.Date) or \
+                        isinstance(prop, hyperdb.Interval):
+                    row.append(repr(entry[name].get_tuple()))
+                elif isinstance(prop, hyperdb.Password):
+                    row.append(repr(str(entry[name])))
+                else:
+                    row.append(repr(entry[name]))
+            elif isinstance(prop, hyperdb.Multilink):
+                row.append('[]')
+            elif name in ('creator', 'actor'):
+                row.append("'1'")
+            elif name in ('created', 'activity'):
+                row.append(repr(today.get_tuple()))
+            else:
+                row.append('None')
+        row.append(entry.get('is retired', False))
+        writer.writerow(row)
+
+        if isinstance(klass, hyperdb.FileClass) and entry.get('content'):
+            fname = klass.exportFilename('/tmp/imported/', entry['id'])
+            support.ensureParentsExist(fname)
+            c = open(fname, 'w')
+            if isinstance(entry['content'], unicode):
+                c.write(entry['content'].encode('utf8'))
+            else:
+                c.write(entry['content'])
+            c.close()
+
+    f.close()
+    f = open('/tmp/imported/%s-journals.csv'%klass.classname, 'w')
+    f.close()
+
+if __name__ == '__main__':
+    if sys.argv[1] == 'import':
+        import_xml(*sys.argv[2:])
+    elif sys.argv[1] == 'files':
+        fetch_files(*sys.argv[2:])
+

Added: tracker/vendor/roundup/current/scripts/roundup-reminder
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/scripts/roundup-reminder	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,170 @@
+#! /usr/bin/env python2.2
+# Copyright (c) 2002 ekit.com Inc (http://www.ekit-inc.com/)
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+#   The above copyright notice and this permission notice shall be included in
+#   all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+# $Id: roundup-reminder,v 1.8 2004/02/11 00:21:46 richard Exp $
+
+'''
+Simple script that emails all users of a tracker with the issues that
+are currently assigned to them.
+
+TODO: introduce some structure ;)
+TODO: possibly make this more general and configurable...
+'''
+
+import sys, cStringIO, MimeWriter, smtplib
+from roundup import instance, date
+from roundup.mailer import SMTPConnection
+
+# open the instance
+if len(sys.argv) != 2:
+    print 'You need to specify an instance home dir'
+instance_home = sys.argv[1]
+instance = instance.open(instance_home)
+db = instance.open('admin')
+
+resolved_id = db.status.lookup('resolved')
+
+def listCompare(x, y):
+    "compare two tuples such that order is positive on [0] and negative on [1]"
+    if x[0] < y[0]:
+        return -1
+    if x[0] > y[0]:
+        return 1
+    if x[1] > y[1]:
+        return -1
+    if x[1] < y[1]:
+        return 1
+    return 0
+
+# loop through all the users
+for user_id in db.user.list():
+    # make sure we care aboue this user
+    name = db.user.get(user_id, 'realname')
+    if name is None:
+        name = db.user.get(user_id, 'username')
+    address = db.user.get(user_id, 'address')
+    if address is None:
+        continue
+
+    # extract this user's issues
+    l = []
+    for issue_id in db.issue.find(assignedto=user_id):
+        if db.issue.get(issue_id, 'status') == resolved_id:
+            continue
+        order = db.priority.get(db.issue.get(issue_id, 'priority'), 'order')
+        l.append((order, db.issue.get(issue_id, 'activity'),
+            db.issue.get(issue_id, 'creation'), issue_id))
+
+    # sort the issues by timeliness and creation date
+    l.sort(listCompare)
+    if not l:
+        continue
+
+    # generate the email message
+    message = cStringIO.StringIO()
+    writer = MimeWriter.MimeWriter(message)
+    writer.addheader('Subject', 'Your active %s issues'%db.config.TRACKER_NAME)
+    writer.addheader('To', address)
+    writer.addheader('From', '%s <%s>'%(db.config.TRACKER_NAME,
+        db.config.ADMIN_EMAIL))
+    writer.addheader('Reply-To', '%s <%s>'%(db.config.TRACKER_NAME,
+        db.config.ADMIN_EMAIL))
+    writer.addheader('MIME-Version', '1.0')
+    writer.addheader('X-Roundup-Name', db.config.TRACKER_NAME)
+
+    # start the multipart
+    part = writer.startmultipartbody('alternative')
+    part = writer.nextpart()
+    body = part.startbody('text/plain')
+    
+    # do the plain text bit
+    print >>body, 'Created     ID   Urgency   Title'
+    print >>body, '='*75
+    #             '2 months    213  immediate cc_daemon barfage
+    old_priority = None
+    for priority_order, activity_date, creation_date, issue_id in l:
+        priority = db.issue.get(issue_id, 'priority')
+        if (priority != old_priority):
+            old_priority = priority
+            print >>body, '    ', db.priority.get(priority,'name')
+        # pretty creation
+        creation = (date.Date('.') - creation_date).pretty()
+        if creation is None:
+            creation = creation_date.pretty()
+        activity = (date.Date('.') - activity_date).pretty()
+        title = db.issue.get(issue_id, 'title')
+        if len(title) > 42:
+            title = title[:38] + ' ...'
+        print >>body, '%-11s %-4s %-9s %-42s'%(creation, issue_id,
+            activity, title)
+
+    # some help to finish off
+    print >>body, '''
+To view or respond to any of the issues listed above, visit the URL
+
+   %s
+
+and click on "My Issues". Do NOT respond to this message.
+'''%db.config.TRACKER_WEB
+
+
+    # now the HTML one
+    part = writer.nextpart()
+    body = part.startbody('text/html')
+    colours = {
+        'immediate': ' bgcolor="#ffcdcd"',
+        'day': ' bgcolor="#ffdecd"',
+        'week': ' bgcolor="#ffeecd"',
+        'month': ' bgcolor="#ffffcd"',
+        'whenever': ' bgcolor="#ffffff"',
+    }
+    print >>body, '''<table border>
+<tr><th>Created</th> <th>ID</th> <th>Activity</th> <th>Title</th></tr>
+'''
+    old_priority = None
+    for priority_order, activity_date, creation_date, issue_id in l:
+        priority = db.issue.get(issue_id,'priority')
+        if (priority != old_priority):
+           old_priority = priority
+           print >>body, '<tr><td>-></td><td>-></td><td>-></td><td><b>%s</b></td></tr>'%db.priority.get(priority,'name')
+        creation = (date.Date('.') - creation_date).pretty()
+        if creation is None:
+            creation = creation_date.pretty()
+        title = db.issue.get(issue_id, 'title')
+        issue_id = '<a href="%sissue%s">%s</a>'%(db.config.TRACKER_WEB,
+            issue_id, issue_id)
+        activity = (date.Date('.') - activity_date).pretty()
+        print >>body, '''<tr><td>%s</td><td>%s</td><td>%s</td>
+    <td>%s</td></tr>'''%(creation, issue_id, activity, title)
+    print >>body, '</table>'
+
+    print >>body, '''<p>To view or respond to any of the issues listed
+        above, simply click on the issue ID. Do <b>not</b> respond to
+        this message.</p>'''
+
+    # finish of the multipart
+    writer.lastpart()
+
+    # all done, send!
+    smtp = SMTPConnection(db.config)
+    smtp.sendmail(db.config.ADMIN_EMAIL, address, message.getvalue())
+
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/scripts/roundup.rc-debian
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/scripts/roundup.rc-debian	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,51 @@
+#!/bin/sh -e
+#
+# roundup	Startup script for the roundup http server.
+#
+# Version:	$Id: roundup.rc-debian,v 1.1 2003/10/07 23:02:58 richard Exp $
+
+DESC='Roundup HTTP-Server'
+
+BINFILE=roundup-server
+EXECUTABLE=/usr/local/bin/$BINFILE
+PIDFILE=/var/run/roundup/server.pid
+LOGFILE=/var/log/roundup/roundup.log
+TRACKERS=tttech=/tttech/org/software/roundup/tttech/
+OPTIONS="-- -p 8080 -u roundup -d $PIDFILE -l $LOGFILE $TRACKERS"
+
+
+test -x $EXECUTABLE || exit 0
+
+start_stop() {
+	case "$1" in
+	start)
+		printf "Starting $DESC:"
+		start-stop-daemon --start --oknodo --quiet \
+                                  --pidfile $PIDFILE \
+				  --exec $EXECUTABLE $OPTIONS
+		printf " $BINFILE"
+		printf ".\n"
+		;;
+	stop)
+		printf "Stopping $DESC:"
+		start-stop-daemon --stop --oknodo --quiet \
+                                  --pidfile $PIDFILE \
+				  --exec $EXECUTABLE $OPTIONS
+		printf " $BINFILE"
+		printf ".\n"
+		;;
+	restart | force-reload)
+		start_stop stop
+		sleep 1
+		start_stop start
+		;;
+	*)
+		printf "Usage: $0 {start|stop|restart|force-reload}\n" >&2
+		exit 1
+		;;
+	esac
+}
+
+start_stop "$@"
+
+exit 0

Added: tracker/vendor/roundup/current/scripts/schema_diagram.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/scripts/schema_diagram.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,48 @@
+#! /usr/bin/python
+#
+# Schema diagram generator contributed by Stefan Seefeld of the fresco
+# project http://www.fresco.org/.
+#
+# It generates a 'dot file' that is then fed into the 'dot'
+# tool (http://www.graphviz.org) to generate a graph:
+#
+# %> ./schema.py
+# %> dot -Tps schema.dot -o schema.ps
+# %> gv schema.ps
+#
+import sys
+import roundup.instance
+
+# open the instance
+instance = roundup.instance.open(sys.argv[1])
+db = instance.open()
+
+# diagram preamble
+print 'digraph schema {'
+print 'size="8,6"'
+print 'node [shape="record" bgcolor="#ffe4c4" style=filled]'
+print 'edge [taillabel="1" headlabel="1" dir=back arrowtail=ediamond]'
+
+# get all the classes
+types = db.classes.keys()
+
+# one record node per class
+for i in range(len(types)):
+    print 'node%d [label=\"{%s|}"]'%(i, types[i])
+
+# now draw in the relations
+for name in db.classes.keys():
+    type = db.classes[name]
+    attributes = type.getprops()
+    for a in attributes.keys():
+        attribute = attributes[a]
+        if isinstance(attribute, roundup.hyperdb.Link):
+            print 'node%d -> node%d [label=%s]'%(types.index(name),
+                                                 types.index(attribute.classname),
+                                                 a)
+        elif isinstance(attribute, roundup.hyperdb.Multilink):
+            print 'node%d -> node%d [taillabel="*" label=%s]'%(types.index(name),
+                                                 types.index(attribute.classname),
+                                                 a)
+# all done
+print '}'

Added: tracker/vendor/roundup/current/scripts/server-ctl
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/scripts/server-ctl	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,96 @@
+#!/bin/sh
+
+#
+# Configuration
+#
+CONFFILE="/var/roundup/server-config.ini"
+
+# this will end up with extra space, but it should be ignored in the script
+PIDFILE=`grep '^pidfile' ${CONFFILE} | awk -F = '{print $2}' `
+SERVER="/usr/local/bin/roundup-server -C ${CONFFILE}"
+ERROR=0
+ARGV="$@"
+if [ "x$ARGV" = "x" ] ; then
+    ARGS="help"
+fi
+
+if [ -z "${PIDFILE}" ] ; then
+    echo "pidfile option must be set in configuration file"
+    exit 1
+fi
+
+for ARG in $@ $ARGS
+do
+    # check for pidfile
+    if [ -f $PIDFILE ] ; then
+	PID=`cat $PIDFILE`
+	if [ "x$PID" != "x" ] && kill -0 $PID 2>/dev/null ; then
+	    STATUS="roundup-server (pid $PID) running"
+	    RUNNING=1
+	else
+	    STATUS="roundup-server (pid $PID?) not running"
+	    RUNNING=0
+	fi
+    else
+	STATUS="roundup-server (no pid file) not running"
+	RUNNING=0
+    fi
+
+    case $ARG in
+    start)
+	if [ $RUNNING -eq 1 ] ; then
+	    echo "$0 $ARG: roundup-server (pid $PID) already running"
+	    continue
+	fi
+	if $SERVER ; then
+	    echo "$0 $ARG: roundup-server started"
+	else
+	    echo "$0 $ARG: roundup-server could not be started"
+	    ERROR=1
+	fi
+	;;
+    condstart)
+	if [ $RUNNING -eq 1 ] ; then
+	    continue
+	fi
+	if $SERVER ; then
+	    echo "$0 $ARG: roundup-server started"
+	else
+	    echo "$0 $ARG: roundup-server could not be started"
+	    ERROR=1
+	fi
+	;;
+    stop)
+	if [ $RUNNING -eq 0 ] ; then
+	    echo "$0 $ARG: $STATUS"
+	    continue
+	fi
+	if kill $PID ; then
+	    echo "$0 $ARG: roundup-server stopped"
+	else
+	    echo "$0 $ARG: roundup-server could not be stopped"
+	    ERROR=2
+	fi
+	;;
+    status)
+	echo $STATUS
+	;;
+    *)
+	echo "usage: $0 (start|condstart|stop|status)"
+	cat <<EOF
+
+    start      - start roundup-server
+    condstart  - start roundup-server if it's not running
+    stop       - stop roundup-server
+    status     - display roundup-server status
+
+EOF
+	ERROR=3
+    ;;
+
+    esac
+
+done
+
+exit $ERROR
+

Added: tracker/vendor/roundup/current/setup.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/setup.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,418 @@
+#! /usr/bin/env python
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+#
+# $Id: setup.py,v 1.89 2006/03/03 02:29:09 richard Exp $
+
+from distutils.core import setup, Extension
+from distutils.util import get_platform
+from distutils.file_util import write_file
+from distutils.command.bdist_rpm import bdist_rpm
+from distutils.command.build import build
+from distutils.command.build_scripts import build_scripts
+from distutils.command.build_py import build_py
+
+import sys, os, string
+from glob import glob
+
+# patch distutils if it can't cope with the "classifiers" keyword
+from distutils.dist import DistributionMetadata
+if not hasattr(DistributionMetadata, 'classifiers'):
+    DistributionMetadata.classifiers = None
+    DistributionMetadata.download_url = None
+
+from roundup import msgfmt
+
+#############################################################################
+### Build script files
+#############################################################################
+
+class build_scripts_create(build_scripts):
+    """ Overload the build_scripts command and create the scripts
+        from scratch, depending on the target platform.
+
+        You have to define the name of your package in an inherited
+        class (due to the delayed instantiation of command classes
+        in distutils, this cannot be passed to __init__).
+
+        The scripts are created in an uniform scheme: they start the
+        run() function in the module
+
+            <packagename>.scripts.<mangled_scriptname>
+
+        The mangling of script names replaces '-' and '/' characters
+        with '-' and '.', so that they are valid module paths.
+
+        If the target platform is win32, create .bat files instead of
+        *nix shell scripts.  Target platform is set to "win32" if main
+        command is 'bdist_wininst' or if the command is 'bdist' and
+        it has the list of formats (from command line or config file)
+        and the first item on that list is wininst.  Otherwise
+        target platform is set to current (build) platform.
+    """
+    package_name = None
+
+    def initialize_options(self):
+        build_scripts.initialize_options(self)
+        self.script_preamble = None
+        self.target_platform = None
+        self.python_executable = None
+
+    def finalize_options(self):
+        build_scripts.finalize_options(self)
+        cmdopt=self.distribution.command_options
+
+        # find the target platform
+        if self.target_platform:
+            # TODO? allow explicit setting from command line
+            target = self.target_platform
+        if cmdopt.has_key("bdist_wininst"):
+            target = "win32"
+        elif cmdopt.get("bdist", {}).has_key("formats"):
+            formats = cmdopt["bdist"]["formats"][1].split(",")
+            if formats[0] == "wininst":
+                target = "win32"
+            else:
+                target = sys.platform
+            if len(formats) > 1:
+                self.warn(
+                    "Scripts are built for %s only (requested formats: %s)"
+                    % (target, ",".join(formats)))
+        else:
+            # default to current platform
+            target = sys.platform
+        self.target_platfom = target
+
+        # for native builds, use current python executable path;
+        # for cross-platform builds, use default executable name
+        if self.python_executable:
+            # TODO? allow command-line option
+            pass
+        if target == sys.platform:
+            self.python_executable = os.path.normpath(sys.executable)
+        else:
+            self.python_executable = "python"
+
+        # for windows builds, add ".bat" extension
+        if target == "win32":
+            # *nix-like scripts may be useful also on win32 (cygwin)
+            # to build both script versions, use:
+            #self.scripts = list(self.scripts) + [script + ".bat"
+            #    for script in self.scripts]
+            self.scripts = [script + ".bat" for script in self.scripts]
+
+        # tweak python path for installations outside main python library
+        if cmdopt.get("install", {}).has_key("prefix"):
+            prefix = os.path.expanduser(cmdopt['install']['prefix'][1])
+            version = '%d.%d'%sys.version_info[:2]
+            self.script_preamble = '''
+import sys
+sys.path.insert(1, "%s/lib/python%s/site-packages")
+'''%(prefix, version)
+        else:
+            self.script_preamble = ''
+
+    def copy_scripts(self):
+        """ Create each script listed in 'self.scripts'
+        """
+        if not self.package_name:
+            raise Exception("You have to inherit build_scripts_create and"
+                " provide a package name")
+
+        to_module = string.maketrans('-/', '_.')
+
+        self.mkpath(self.build_dir)
+        for script in self.scripts:
+            outfile = os.path.join(self.build_dir, os.path.basename(script))
+
+            #if not self.force and not newer(script, outfile):
+            #    self.announce("not copying %s (up-to-date)" % script)
+            #    continue
+
+            if self.dry_run:
+                self.announce("would create %s" % outfile)
+                continue
+
+            module = os.path.splitext(os.path.basename(script))[0]
+            module = string.translate(module, to_module)
+            script_vars = {
+                'python': self.python_executable,
+                'package': self.package_name,
+                'module': module,
+                'prefix': self.script_preamble,
+            }
+
+            self.announce("creating %s" % outfile)
+            file = open(outfile, 'w')
+
+            try:
+                # could just check self.target_platform,
+                # but looking at the script extension
+                # makes it possible to build both *nix-like
+                # and windows-like scripts on win32.
+                # may be useful for cygwin.
+                if os.path.splitext(outfile)[1] == ".bat":
+                    file.write('@echo off\n'
+                        'if NOT "%%_4ver%%" == "" "%(python)s" -c "from %(package)s.scripts.%(module)s import run; run()" %%$\n'
+                        'if     "%%_4ver%%" == "" "%(python)s" -c "from %(package)s.scripts.%(module)s import run; run()" %%*\n'
+                        % script_vars)
+                else:
+                    file.write('#! %(python)s\n%(prefix)s'
+                        'from %(package)s.scripts.%(module)s import run\n'
+                        'run()\n'
+                        % script_vars)
+            finally:
+                file.close()
+                os.chmod(outfile, 0755)
+
+
+class build_scripts_roundup(build_scripts_create):
+    package_name = 'roundup'
+
+
+def scriptname(path):
+    """ Helper for building a list of script names from a list of
+        module files.
+    """
+    script = os.path.splitext(os.path.basename(path))[0]
+    script = string.replace(script, '_', '-')
+    return script
+
+### Build Roundup
+
+def list_message_files(suffix=".po"):
+    """Return list of all found message files and their intallation paths"""
+    _files = glob("locale/*" + suffix)
+    _list = []
+    for _file in _files:
+        # basename (without extension) is a locale name
+        _locale = os.path.splitext(os.path.basename(_file))[0]
+        _list.append((_file, os.path.join(
+            "share", "locale", _locale, "LC_MESSAGES", "roundup.mo")))
+    return _list
+
+def check_manifest():
+    """Check that the files listed in the MANIFEST are present when the
+    source is unpacked.
+    """
+    try:
+        f = open('MANIFEST')
+    except:
+        print '\n*** SOURCE WARNING: The MANIFEST file is missing!'
+        return
+    try:
+        manifest = [l.strip() for l in f.readlines()]
+    finally:
+        f.close()
+    err = [line for line in manifest if not os.path.exists(line)]
+    if err:
+        n = len(manifest)
+        print '\n*** SOURCE WARNING: There are files missing (%d/%d found)!'%(
+            n-len(err), n)
+        print 'Missing:', '\nMissing: '.join(err)
+
+
+class build_py_roundup(build_py):
+
+    def find_modules(self):
+        # Files listed in py_modules are in the toplevel directory
+        # of the source distribution.
+        modules = []
+        for module in self.py_modules:
+            path = string.split(module, '.')
+            package = string.join(path[0:-1], '.')
+            module_base = path[-1]
+            module_file = module_base + '.py'
+            if self.check_module(module, module_file):
+                modules.append((package, module_base, module_file))
+        return modules
+
+
+class build_roundup(build):
+
+    def build_message_files(self):
+        """For each locale/*.po, build .mo file in target locale directory"""
+        for (_src, _dst) in list_message_files():
+            _build_dst = os.path.join("build", _dst)
+            self.mkpath(os.path.dirname(_build_dst))
+            self.announce("Compiling %s -> %s" % (_src, _build_dst))
+            msgfmt.make(_src, _build_dst)
+
+    def run(self):
+        check_manifest()
+        self.build_message_files()
+        build.run(self)
+
+class bdist_rpm_roundup(bdist_rpm):
+
+    def finalize_options(self):
+        bdist_rpm.finalize_options(self)
+        if self.install_script:
+            # install script is overridden.  skip default
+            return
+        # install script option must be file name.
+        # create the file in rpm build directory.
+        install_script = os.path.join(self.rpm_base, "install.sh")
+        self.mkpath(self.rpm_base)
+        self.execute(write_file, (install_script, [
+                ("%s setup.py install --root=$RPM_BUILD_ROOT "
+                    "--record=ROUNDUP_FILES") % self.python,
+                # allow any additional extension for man pages
+                # (rpm may compress them to .gz or .bz2)
+                # man page here is any file
+                # with single-character extension
+                # in man directory
+                "sed -e 's,\(/man/.*\..\)$,\\1*,' "
+                    "<ROUNDUP_FILES >INSTALLED_FILES",
+            ]), "writing '%s'" % install_script)
+        self.install_script = install_script
+
+#############################################################################
+### Main setup stuff
+#############################################################################
+
+def main():
+    # build list of scripts from their implementation modules
+    roundup_scripts = map(scriptname, glob('roundup/scripts/[!_]*.py'))
+
+    # template munching
+    packagelist = [
+        'roundup',
+        'roundup.cgi',
+        'roundup.cgi.PageTemplates',
+        'roundup.cgi.TAL',
+        'roundup.cgi.ZTUtils',
+        'roundup.backends',
+        'roundup.scripts',
+    ]
+    installdatafiles = [
+        ('share/roundup/cgi-bin', ['cgi-bin/roundup.cgi']),
+    ]
+    py_modules = ['roundup.demo',]
+
+    # install man pages on POSIX platforms
+    if os.name == 'posix':
+        installdatafiles.append(('man/man1', ['doc/roundup-admin.1',
+            'doc/roundup-mailgw.1', 'doc/roundup-server.1',
+            'doc/roundup-demo.1']))
+
+    # add the templates to the data files lists
+    from roundup.init import listTemplates
+    templates = [t['path'] for t in listTemplates('templates').values()]
+    for tdir in templates:
+        # scan for data files
+        for idir in '. detectors extensions html'.split():
+            idir = os.path.join(tdir, idir)
+            if not os.path.isdir(idir):
+                continue
+            tfiles = []
+            for f in os.listdir(idir):
+                if f.startswith('.'):
+                    continue
+                ifile = os.path.join(idir, f)
+                if os.path.isfile(ifile):
+                    tfiles.append(ifile)
+            installdatafiles.append(
+                (os.path.join('share', 'roundup', idir), tfiles)
+            )
+
+    # add message files
+    for (_dist_file, _mo_file) in list_message_files():
+        installdatafiles.append((os.path.dirname(_mo_file),
+            [os.path.join("build", _mo_file)]))
+
+    # perform the setup action
+    from roundup import __version__
+    setup_args = {
+        'name': "roundup",
+        'version': __version__,
+        'description': "A simple-to-use and -install issue-tracking system"
+            " with command-line, web and e-mail interfaces. Highly"
+            " customisable.",
+        'long_description':
+'''Roundup is a simple-to-use and -install issue-tracking system with
+command-line, web and e-mail interfaces. It is based on the winning design
+from Ka-Ping Yee in the Software Carpentry "Track" design competition.
+
+If you're upgrading from an older version of Roundup you *must* follow
+the "Software Upgrade" guidelines given in the maintenance documentation.
+
+Fixed:
+
+- failure with browsers not sending "Accept-Language" header
+  (sf bugs 1429646 and 1435335)
+- translate class name in "required property not supplied" error message
+  (sf bug 1429669)
+- error in link property lookups with numeric-alike key values (sf bug 1424550)
+- ignore UTF-8 BOM in .po files
+- add permission filter to menu() implementations (sf bug 1431188)
+- lithuanian translation updated by Nerijus Baliunas (sf patch 1411175)
+- incompatibility with python2.3 in the mailer module (sf bug 1432602)
+- typo in SMTP TLS option name: "MAIL_TLS_CERFILE" (sf bug 1435452)
+- email obfuscation code in html templating is more robust
+- blank-title subject line handling (sf bug 1442121)
+- "All users may only view and edit issues, files and messages they
+  create" example in docs (sf bug 1439086)
+- saving of queries (sf bug 1436169)
+- "Adding a new constrained field to the classic schema" example in docs
+  (sf bug 1433118)
+- security check in mailgw (sf bug 1442145)
+- "clear this message" (sf bug 1429367)
+- escape all uses of "schema" in mysql backend (sf bug 1397569)
+- date spec wasn't allowing week intervals
+''',
+        'author': "Richard Jones",
+        'author_email': "richard at users.sourceforge.net",
+        'url': 'http://roundup.sourceforge.net/',
+        'packages': packagelist,
+        'classifiers': [
+            'Development Status :: 5 - Production/Stable',
+            'Environment :: Console',
+            'Environment :: Web Environment',
+            'Intended Audience :: End Users/Desktop',
+            'Intended Audience :: Developers',
+            'Intended Audience :: System Administrators',
+            'License :: OSI Approved :: Python Software Foundation License',
+            'Operating System :: MacOS :: MacOS X',
+            'Operating System :: Microsoft :: Windows',
+            'Operating System :: POSIX',
+            'Programming Language :: Python',
+            'Topic :: Communications :: Email',
+            'Topic :: Office/Business',
+            'Topic :: Software Development :: Bug Tracking',
+        ],
+
+        # Override certain command classes with our own ones
+        'cmdclass': {
+            'build_scripts': build_scripts_roundup,
+            'build_py': build_py_roundup,
+            'build': build_roundup,
+            'bdist_rpm': bdist_rpm_roundup,
+        },
+        'scripts': roundup_scripts,
+
+        'data_files':  installdatafiles
+    }
+    if sys.version_info[:2] > (2, 2):
+       setup_args['py_modules'] = py_modules
+
+    setup(**setup_args)
+
+if __name__ == '__main__':
+    main()
+
+# vim: set filetype=python sts=4 sw=4 et si :

Added: tracker/vendor/roundup/current/templates/classic/.cvsignore
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/.cvsignore	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,4 @@
+*.pyc
+*.pyo
+htmlbase.py
+*.cover

Added: tracker/vendor/roundup/current/templates/classic/TEMPLATE-INFO.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/TEMPLATE-INFO.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,7 @@
+Name: classic
+Description: This is a generic issue tracker that may be used to track bugs,
+             feature requests, project issues or any number of other types
+             of issues. Most users of Roundup will find that this template
+             suits them, with perhaps a few customisations.
+Intended-For: All first-time Roundup users
+

Added: tracker/vendor/roundup/current/templates/classic/detectors/.cvsignore
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/detectors/.cvsignore	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,3 @@
+*.pyc
+*.pyo
+*.cover

Added: tracker/vendor/roundup/current/templates/classic/detectors/messagesummary.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/detectors/messagesummary.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,19 @@
+#$Id: messagesummary.py,v 1.1 2003/04/17 03:26:38 richard Exp $
+
+from roundup.mailgw import parseContent
+
+def summarygenerator(db, cl, nodeid, newvalues):
+    ''' If the message doesn't have a summary, make one for it.
+    '''
+    if newvalues.has_key('summary') or not newvalues.has_key('content'):
+        return
+
+    summary, content = parseContent(newvalues['content'], 1, 1)
+    newvalues['summary'] = summary
+
+
+def init(db):
+    # fire before changes are made
+    db.msg.audit('create', summarygenerator)
+
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/templates/classic/detectors/nosyreaction.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/detectors/nosyreaction.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,143 @@
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+# 
+#$Id: nosyreaction.py,v 1.4 2005/04/04 08:47:14 richard Exp $
+
+import sets
+
+from roundup import roundupdb, hyperdb
+
+def nosyreaction(db, cl, nodeid, oldvalues):
+    ''' A standard detector is provided that watches for additions to the
+        "messages" property.
+        
+        When a new message is added, the detector sends it to all the users on
+        the "nosy" list for the issue that are not already on the "recipients"
+        list of the message.
+        
+        Those users are then appended to the "recipients" property on the
+        message, so multiple copies of a message are never sent to the same
+        user.
+        
+        The journal recorded by the hyperdatabase on the "recipients" property
+        then provides a log of when the message was sent to whom. 
+    '''
+    # send a copy of all new messages to the nosy list
+    for msgid in determineNewMessages(cl, nodeid, oldvalues):
+        try:
+            cl.nosymessage(nodeid, msgid, oldvalues)
+        except roundupdb.MessageSendError, message:
+            raise roundupdb.DetectorError, message
+
+def determineNewMessages(cl, nodeid, oldvalues):
+    ''' Figure a list of the messages that are being added to the given
+        node in this transaction.
+    '''
+    messages = []
+    if oldvalues is None:
+        # the action was a create, so use all the messages in the create
+        messages = cl.get(nodeid, 'messages')
+    elif oldvalues.has_key('messages'):
+        # the action was a set (so adding new messages to an existing issue)
+        m = {}
+        for msgid in oldvalues['messages']:
+            m[msgid] = 1
+        messages = []
+        # figure which of the messages now on the issue weren't there before
+        for msgid in cl.get(nodeid, 'messages'):
+            if not m.has_key(msgid):
+                messages.append(msgid)
+    return messages
+
+def updatenosy(db, cl, nodeid, newvalues):
+    '''Update the nosy list for changes to the assignedto
+    '''
+    # nodeid will be None if this is a new node
+    current_nosy = sets.Set()
+    if nodeid is None:
+        ok = ('new', 'yes')
+    else:
+        ok = ('yes',)
+        # old node, get the current values from the node if they haven't
+        # changed
+        if not newvalues.has_key('nosy'):
+            nosy = cl.get(nodeid, 'nosy')
+            for value in nosy:
+                current_nosy.add(value)
+
+    # if the nosy list changed in this transaction, init from the new value
+    if newvalues.has_key('nosy'):
+        nosy = newvalues.get('nosy', [])
+        for value in nosy:
+            if not db.hasnode('user', value):
+                continue
+            current_nosy.add(value)
+
+    new_nosy = sets.Set(current_nosy)
+
+    # add assignedto(s) to the nosy list
+    if newvalues.has_key('assignedto') and newvalues['assignedto'] is not None:
+        propdef = cl.getprops()
+        if isinstance(propdef['assignedto'], hyperdb.Link):
+            assignedto_ids = [newvalues['assignedto']]
+        elif isinstance(propdef['assignedto'], hyperdb.Multilink):
+            assignedto_ids = newvalues['assignedto']
+        for assignedto_id in assignedto_ids:
+            new_nosy.add(assignedto_id)
+
+    # see if there's any new messages - if so, possibly add the author and
+    # recipient to the nosy
+    if newvalues.has_key('messages'):
+        if nodeid is None:
+            ok = ('new', 'yes')
+            messages = newvalues['messages']
+        else:
+            ok = ('yes',)
+            # figure which of the messages now on the issue weren't
+            oldmessages = cl.get(nodeid, 'messages')
+            messages = []
+            for msgid in newvalues['messages']:
+                if msgid not in oldmessages:
+                    messages.append(msgid)
+
+        # configs for nosy modifications
+        add_author = getattr(db.config, 'ADD_AUTHOR_TO_NOSY', 'new')
+        add_recips = getattr(db.config, 'ADD_RECIPIENTS_TO_NOSY', 'new')
+
+        # now for each new message:
+        msg = db.msg
+        for msgid in messages:
+            if add_author in ok:
+                authid = msg.get(msgid, 'author')
+                new_nosy.add(authid)
+
+            # add on the recipients of the message
+            if add_recips in ok:
+                for recipient in msg.get(msgid, 'recipients'):
+                    new_nosy.add(recipient)
+
+    if current_nosy != new_nosy:
+        # that's it, save off the new nosy list
+        newvalues['nosy'] = list(new_nosy)
+
+def init(db):
+    db.issue.react('create', nosyreaction)
+    db.issue.react('set', nosyreaction)
+    db.issue.audit('create', updatenosy)
+    db.issue.audit('set', updatenosy)
+
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/templates/classic/detectors/statusauditor.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/detectors/statusauditor.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,85 @@
+# Copyright (c) 2002 ekit.com Inc (http://www.ekit-inc.com/)
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+#   The above copyright notice and this permission notice shall be included in
+#   all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+#
+#$Id: statusauditor.py,v 1.5 2004/03/27 00:01:48 richard Exp $
+
+def chatty(db, cl, nodeid, newvalues):
+    ''' If the issue is currently 'unread', 'resolved', 'done-cbb' or None,
+        then set it to 'chatting'
+    '''
+    # don't fire if there's no new message (ie. chat)
+    if not newvalues.has_key('messages'):
+        return
+    if newvalues['messages'] == cl.get(nodeid, 'messages'):
+        return
+
+    # get the chatting state ID
+    try:
+        chatting_id = db.status.lookup('chatting')
+    except KeyError:
+        # no chatting state, ignore all this stuff
+        return
+
+    # get the current value
+    current_status = cl.get(nodeid, 'status')
+
+    # see if there's an explicit change in this transaction
+    if newvalues.has_key('status'):
+        # yep, skip
+        return
+
+    # determine the id of 'unread', 'resolved' and 'chatting'
+    fromstates = []
+    for state in 'unread resolved done-cbb'.split():
+        try:
+            fromstates.append(db.status.lookup(state))
+        except KeyError:
+            pass
+
+    # ok, there's no explicit change, so check if we are in a state that
+    # should be changed
+    if current_status in fromstates + [None]:
+        # yep, we're now chatting
+        newvalues['status'] = chatting_id
+
+
+def presetunread(db, cl, nodeid, newvalues):
+    ''' Make sure the status is set on new issues
+    '''
+    if newvalues.has_key('status') and newvalues['status']:
+        return
+
+    # get the unread state ID
+    try:
+        unread_id = db.status.lookup('unread')
+    except KeyError:
+        # no unread state, ignore all this stuff
+        return
+
+    # ok, do it
+    newvalues['status'] = unread_id
+
+
+def init(db):
+    # fire before changes are made
+    db.issue.audit('set', chatty)
+    db.issue.audit('create', presetunread)
+
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/templates/classic/detectors/userauditor.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/detectors/userauditor.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,44 @@
+# Copyright (c) 2003 Richard Jones (richard at mechanicalcat.net)
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+#   The above copyright notice and this permission notice shall be included in
+#   all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+#
+#$Id: userauditor.py,v 1.2 2003/11/11 22:25:37 richard Exp $
+
+def audit_user_fields(db, cl, nodeid, newvalues):
+    ''' Make sure user properties are valid.
+
+        - email address has no spaces in it
+        - roles specified exist
+    '''
+    if newvalues.has_key('address') and ' ' in newvalues['address']:
+        raise ValueError, 'Email address must not contain spaces'
+
+    if newvalues.has_key('roles'):
+        roles = [x.lower().strip() for x in newvalues['roles'].split(',')]
+        for rolename in roles:
+            if not db.security.role.has_key(rolename):
+                raise ValueError, 'Role "%s" does not exist'%rolename
+
+
+def init(db):
+    # fire before changes are made
+    db.user.audit('set', audit_user_fields)
+    db.user.audit('create', audit_user_fields)
+
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/templates/classic/extensions/README.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/extensions/README.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,6 @@
+This directory is for tracker extensions:
+
+- CGI Actions
+- Templating functions
+
+See the customisation doc for more information.

Added: tracker/vendor/roundup/current/templates/classic/html/_generic.calendar.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/html/_generic.calendar.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,18 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html>
+ <head>
+  <link rel="stylesheet" type="text/css" href="@@file/style.css" />
+  <meta http-equiv="Content-Type" content="text/html; charset=utf-8;" />
+  <title tal:content="string:Roundup Calendar"></title>
+  <script language="Javascript"
+          type="text/javascript"
+          tal:content="structure string:
+          // this is the name of the field in the original form that we're working on
+          form  = window.opener.document.${request/form/form/value};
+          field = '${request/form/property/value}';" >
+  </script>
+ </head>
+ <body class="body"
+       tal:content="structure python:utils.html_calendar(request)">
+ </body>
+</html>

Added: tracker/vendor/roundup/current/templates/classic/html/_generic.collision.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/html/_generic.collision.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,16 @@
+<tal:block metal:use-macro="templates/page/macros/icing">
+<title metal:fill-slot="head_title" i18n:translate=""
+ ><span tal:replace="python:context._classname.capitalize()"
+ i18n:name="class" /> Edit Collision - <span i18n:name="tracker"
+ tal:replace="config/TRACKER_NAME" /></title>
+<tal:block metal:fill-slot="body_title" i18n:translate=""
+ ><span tal:replace="python:context._classname.capitalize()"
+ i18n:name="class" /> Edit Collision</tal:block>
+
+<td class="content" metal:fill-slot="content" i18n:translate="
+  There has been a collision. Another user updated this node
+  while you were editing. Please <a href='${context}'>reload</a>
+  the node and review your edits.
+"><span tal:replace="context/designator" i18n:name="context" />
+</td>
+</tal:block>

Added: tracker/vendor/roundup/current/templates/classic/html/_generic.help.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/html/_generic.help.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,98 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html tal:define="property request/form/property/value" >
+  <head>
+      <link rel="stylesheet" type="text/css" href="@@file/style.css" />
+      <meta http-equiv="Content-Type"
+       tal:attributes="content string:text/html;; charset=${request/client/charset}" />
+      <tal:block tal:condition="python:request.form.has_key('property')">
+      <title i18n:translate=""><tal:x i18n:name="property"
+       tal:content="property" i18n:translate="" /> help - <span i18n:name="tracker"
+       tal:replace="config/TRACKER_NAME" /></title>
+      <script language="Javascript" type="text/javascript"
+          tal:content="structure string:
+          // this is the name of the field in the original form that we're working on
+          form  = window.opener.document.${request/form/form/value};
+          field  = '${request/form/property/value}';">
+      </script>
+      <script src="@@file/help_controls.js" type="text/javascript"><!--
+      //--></script>
+      </tal:block>
+  </head>
+ <body class="body" onload="resetList();">
+ <form name="frm_help" tal:attributes="action request/base"
+       tal:define="batch request/batch;
+                   props python:request.form['properties'].value.split(',')">
+
+     <div id="classhelp-controls">
+       <!--input type="button" name="btn_clear"
+              value="Clear" onClick="clearList()"/ -->
+       <input type="text" name="text_preview" size="24" class="preview"
+              onchange="reviseList(this.value);"/>
+       <input type="button" name="btn_reset"
+              value=" Cancel " onclick="resetList(); window.close();"
+              i18n:attributes="value" />
+       <input type="button" name="btn_apply" class="apply"
+              value=" Apply " onclick="updateList(); window.close();"
+              i18n:attributes="value" />
+     </div>
+     <table width="100%">
+      <tr class="navigation">
+       <th>
+        <a tal:define="prev batch/previous" tal:condition="prev"
+           tal:attributes="href python:request.indexargs_url(request.classname,
+           {'@template':'help', 'property': request.form['property'].value,
+            'properties': request.form['properties'].value,
+            'form': request.form['form'].value,
+            'type': request.form['type'].value,
+            '@startwith':prev.first, '@pagesize':prev.size})"
+           i18n:translate="" >&lt;&lt; previous</a>
+        &nbsp;
+       </th>
+       <th i18n:translate=""><span tal:replace="batch/start" i18n:name="start"
+        />..<span tal:replace="python: batch.start + batch.length -1" i18n:name="end"
+        /> out of <span tal:replace="batch/sequence_length" i18n:name="total"
+        />
+       </th>
+       <th>
+        <a tal:define="next batch/next" tal:condition="next"
+           tal:attributes="href python:request.indexargs_url(request.classname,
+           {'@template':'help', 'property': request.form['property'].value,
+            'properties': request.form['properties'].value,
+            'form': request.form['form'].value,
+            'type': request.form['type'].value,
+            '@startwith':next.first, '@pagesize':next.size})"
+           i18n:translate="" >next &gt;&gt;</a>
+        &nbsp;
+       </th>
+      </tr>
+     </table>
+
+     <table class="classhelp">
+       <tr>
+           <th>&nbsp;<b>x</b></th>
+           <th tal:repeat="prop props" tal:content="prop" i18n:translate=""></th>
+       </tr>
+       <tr tal:repeat="item batch">
+         <tal:block tal:define="attr python:item[props[0]]" >
+           <td>
+             <input name="check"
+                 onclick="updatePreview();"
+                 tal:attributes="type python:request.form['type'].value;
+                                 value attr; id string:id_$attr" />
+             </td>
+             <td tal:repeat="prop props">
+                 <label class="classhelp-label"
+                        tal:attributes="for string:id_$attr"
+                        tal:content="structure python:item[prop]"></label>
+             </td>
+           </tal:block>
+       </tr>
+       <tr>
+           <th>&nbsp;<b>x</b></th>
+           <th tal:repeat="prop props" tal:content="prop" i18n:translate=""></th>
+       </tr>
+     </table>
+
+ </form>
+ </body>
+</html>

Added: tracker/vendor/roundup/current/templates/classic/html/_generic.index.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/html/_generic.index.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,64 @@
+<!-- dollarId: issue.index,v 1.2 2001/07/29 04:07:37 richard Exp dollar-->
+
+<tal:block metal:use-macro="templates/page/macros/icing">
+<title metal:fill-slot="head_title" i18n:translate=""
+ ><span tal:replace="python:context._classname.capitalize()"
+ i18n:name="class" /> editing - <span i18n:name="tracker"
+ tal:replace="config/TRACKER_NAME" /></title>
+<tal:block metal:fill-slot="body_title" i18n:translate=""
+ ><span tal:replace="python:context._classname.capitalize()"
+ i18n:name="class" /> editing</tal:block>
+
+<td class="content" metal:fill-slot="content">
+
+<span tal:condition="python:not (context.is_view_ok() or context.is_edit_ok())"
+ tal:omit-tag="python:1" i18n:translate=""
+>You are not allowed to view this page.</span>
+
+<tal:block tal:condition="context/is_edit_ok">
+<tal:block i18n:translate="">
+<p class="form-help">
+ You may edit the contents of the
+ <span tal:replace="request/classname" i18n:name="classname"/>
+ class using this form. Commas, newlines and double quotes (") must be
+ handled delicately. You may include commas and newlines by enclosing the
+ values in double-quotes ("). Double quotes themselves must be quoted by
+ doubling ("").
+</p>
+
+<p class="form-help">
+ Multilink properties have their multiple values colon (":") separated
+ (... ,"one:two:three", ...)
+</p>
+
+<p class="form-help">
+ Remove entries by deleting their line. Add new entries by appending
+ them to the table - put an X in the id column.
+</p>
+</tal:block>
+<form onSubmit="return submit_once()" method="POST"
+      tal:attributes="action context/designator">
+<textarea rows="15" style="width:90%" name="rows" tal:content="context/csv"></textarea>
+<br>
+<input type="hidden" name="@action" value="editCSV">
+<input type="submit" value="Edit Items" i18n:attributes="value">
+</form>
+</tal:block>
+
+<table tal:condition="context/is_only_view_ok" width="100%" class="list">
+ <tr>
+  <th tal:repeat="property context/propnames" tal:content="property">&nbsp;</th>
+ </tr>
+ <tal:block repeat="item context/list">
+ <tr tal:condition="item/is_view_ok"
+     tal:attributes="class python:['normal', 'alt'][repeat['item'].index%6/3]">
+  <td tal:repeat="property context/propnames"
+   tal:content="python: item[property] or default"
+  >&nbsp;</td>
+ </tr>
+ </tal:block>
+</table>
+
+</td>
+
+</tal:block>

Added: tracker/vendor/roundup/current/templates/classic/html/_generic.item.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/html/_generic.item.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,48 @@
+<tal:block metal:use-macro="templates/page/macros/icing">
+<title metal:fill-slot="head_title" i18n:translate=""
+ ><span tal:replace="python:context._classname.capitalize()"
+ i18n:name="class" /> editing - <span i18n:name="tracker"
+ tal:replace="config/TRACKER_NAME" /></title>
+<tal:block metal:fill-slot="body_title" i18n:translate=""
+ ><span tal:replace="python:context._classname.capitalize()"
+ i18n:name="class" /> editing</tal:block>
+
+<td class="content" metal:fill-slot="content">
+
+<p tal:condition="not:context/is_view_ok" i18n:translate="">You are not
+    allowed to view this page.</p>
+
+<div tal:condition="context/is_view_ok">
+
+<form method="POST" onSubmit="return submit_once()"
+      enctype="multipart/form-data" tal:condition="context/is_view_ok"
+      tal:attributes="action context/designator">
+
+<input type="hidden" name="@template" value="item">
+
+<table class="form">
+
+<tr tal:repeat="prop python:db[context._classname].properties()">
+ <tal:block tal:condition="python:prop._name not in ('id',
+   'creator', 'creation', 'actor', 'activity')">
+  <th tal:content="prop/_name"></th>
+  <td tal:content="structure python:context[prop._name].field()"></td>
+ </tal:block>
+</tr>
+<tr>
+ <td>&nbsp;</td>
+ <td colspan=3 tal:content="structure context/submit">
+  submit button will go here
+ </td>
+</tr>
+</table>
+
+</form>
+
+<tal:block tal:condition="context/id" tal:replace="structure context/history" />
+
+</div>
+
+</td>
+
+</tal:block>

Added: tracker/vendor/roundup/current/templates/classic/html/file.index.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/html/file.index.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,31 @@
+<!-- dollarId: file.index,v 1.4 2002/01/23 05:10:27 richard Exp dollar-->
+<tal:block metal:use-macro="templates/page/macros/icing">
+<title metal:fill-slot="head_title" i18n:translate=""
+ >List of files - <span tal:replace="config/TRACKER_NAME" i18n:name="tracker" /></title>
+<span metal:fill-slot="body_title" tal:omit-tag="python:1"
+  i18n:translate="">List of files</span>
+<td class="content" metal:fill-slot="content">
+
+<table class="otherinfo" tal:define="batch request/batch">
+ <tr><th style="padding-right: 10" i18n:translate="">Download</th>
+     <th style="padding-right: 10" i18n:translate="">Content Type</th>
+     <th style="padding-right: 10" i18n:translate="">Uploaded By</th>
+     <th style="padding-right: 10" i18n:translate="">Date</th>
+ </tr>
+ <tr tal:repeat="file batch" tal:attributes="class python:['normal', 'alt'][repeat['file'].index%6/3]">
+  <td>
+   <a tal:attributes="href string:file${file/id}/${file/name}"
+      tal:content="file/name">dld link</a>
+  </td>
+  <td tal:content="file/type">content type</td>
+  <td tal:content="file/creator">creator's name</td>
+  <td tal:content="file/creation">creation date</td>
+ </tr>
+
+ <metal:block use-macro="templates/issue.index/macros/batch-footer" />
+
+</table>
+
+</td>
+
+</tal:block>

Added: tracker/vendor/roundup/current/templates/classic/html/file.item.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/html/file.item.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,48 @@
+<tal:block metal:use-macro="templates/page/macros/icing">
+<title metal:fill-slot="head_title" i18n:translate="">File display - <span
+ i18n:name="tracker" tal:replace="config/TRACKER_NAME" /></title>
+<span metal:fill-slot="body_title" tal:omit-tag="python:1"
+ i18n:translate="">File display</span>
+
+<td class="content" metal:fill-slot="content">
+
+<p tal:condition="not:context/is_view_ok" i18n:translate="">You are not
+    allowed to view this page.</p>
+
+<form method="POST" onSubmit="return submit_once()"
+      enctype="multipart/form-data" tal:condition="context/is_view_ok"
+      tal:attributes="action context/designator">
+
+<table class="form">
+ <tr>
+  <th i18n:translate="">Name</th>
+  <td tal:content="structure context/name/field"></td>
+ </tr>
+ <tr>
+  <th i18n:translate="">Content Type</th>
+  <td tal:content="structure context/type/field"></td>
+ </tr>
+
+ <tr>
+  <td>
+   &nbsp;
+   <input type="hidden" name="@template" value="item">
+   <input type="hidden" name="@required" value="name,type">
+   <input type="hidden" name="@multilink"
+          tal:condition="python:request.form.has_key('@multilink')"
+          tal:attributes="value request/form/@multilink/value">
+  </td>
+  <td tal:content="structure context/submit">submit button here</td>
+ </tr>
+</table>
+</form>
+
+<a tal:condition="python:context.id and context.is_view_ok()"
+ tal:attributes="href string:file${context/id}/${context/name}"
+ i18n:translate="">download</a>
+
+<tal:block tal:condition="context/id" tal:replace="structure context/history" />
+
+</td>
+
+</tal:block>

Added: tracker/vendor/roundup/current/templates/classic/html/help_controls.js
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/html/help_controls.js	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,123 @@
+// initial values for either Nosy, Superseder, Topic and Waiting On,
+// depending on which has called
+original_field = form[field].value;
+
+// Some browsers (ok, IE) don't define the "undefined" variable.
+undefined = document.geez_IE_is_really_friggin_annoying;
+
+function trim(value) {
+  var temp = value;
+  var obj = /^(\s*)([\W\w]*)(\b\s*$)/;
+  if (obj.test(temp)) { temp = temp.replace(obj, '$2'); }
+  var obj = /  /g;
+  while (temp.match(obj)) { temp = temp.replace(obj, " "); }
+  return temp;
+}
+
+function determineList() {
+     // generate a comma-separated list of the checked items
+     var list = new String('');
+ 
+     // either a checkbox object or an array of checkboxes
+     var check = document.frm_help.check;
+ 
+     if ((check.length == undefined) && (check.checked != undefined)) {
+         // only one checkbox on page
+         if (check.checked) {
+             list = check.value;
+         }
+     } else {
+         // array of checkboxes
+         for (box=0; box < check.length; box++) {
+             if (check[box].checked) {
+                 if (list.length == 0) {
+                     separator = '';
+                 }
+                 else {
+                     separator = ',';
+                 }
+                 // we used to use an Array and push / join, but IE5.0 sux
+                 list = list + separator + check[box].value;
+             }
+         }
+     }
+     return list;  
+}
+
+function updateList() {
+  // write back to opener window
+  if (document.frm_help.check==undefined) { return; }
+  form[field].value = determineList();
+}
+
+function updatePreview() {
+  // update the preview box
+  if (document.frm_help.check==undefined) { return; }
+  writePreview(determineList());
+}
+
+function clearList() {
+  // uncheck all checkboxes
+  if (document.frm_help.check==undefined) { return; }
+  for (box=0; box < document.frm_help.check.length; box++) {
+      document.frm_help.check[box].checked = false;
+  }
+}
+
+function reviseList(vals) {
+  // update the checkboxes based on the preview field
+  if (document.frm_help.check==undefined) { return; }
+  var to_check;
+  var list = vals.split(",");
+  if (document.frm_help.check.length==undefined) {
+      check = document.frm_help.check;
+      to_check = false;
+      for (val in list) {
+          if (check.value==trim(list[val])) {
+              to_check = true;
+              break;
+          }
+      }
+      check.checked = to_check;
+  } else {
+    for (box=0; box < document.frm_help.check.length; box++) {
+      check = document.frm_help.check[box];
+      to_check = false;
+      for (val in list) {
+          if (check.value==trim(list[val])) {
+              to_check = true;
+              break;
+          }
+      }
+      check.checked = to_check;
+    }
+  }
+}
+
+function resetList() {
+  // reset preview and check boxes to initial values
+  if (document.frm_help.check==undefined) { return; }
+  writePreview(original_field);
+  reviseList(original_field);
+}
+
+function writePreview(val) {
+   // writes a value to the text_preview
+   document.frm_help.text_preview.value = val;
+}
+
+function focusField(name) {
+    for(i=0; i < document.forms.length; ++i) {
+      var obj = document.forms[i].elements[name];
+      if (obj && obj.focus) {obj.focus();}
+    }
+}
+
+function selectField(name) {
+    for(i=0; i < document.forms.length; ++i) {
+      var obj = document.forms[i].elements[name];
+      if (obj && obj.focus){obj.focus();} 
+      if (obj && obj.select){obj.select();}
+    }
+}
+

Added: tracker/vendor/roundup/current/templates/classic/html/home.classlist.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/html/home.classlist.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,25 @@
+<tal:block metal:use-macro="templates/page/macros/icing">
+<title metal:fill-slot="head_title" i18n:translate="">List of classes - <span
+ i18n:name="tracker" tal:replace="config/TRACKER_NAME" /></title>
+<span metal:fill-slot="body_title" tal:omit-tag="python:1"
+ i18n:translate="">List of classes</span>
+<td class="content" metal:fill-slot="content">
+<table class="classlist">
+
+<tal:block tal:repeat="cl db/classes">
+ <tr>
+  <th class="header" colspan="2" align="left">
+   <a tal:attributes="href string:${cl/classname}"
+      tal:content="python:cl.classname.capitalize()">classname</a>
+  </th>
+ </tr>
+ <tr tal:repeat="prop cl/properties">
+  <th tal:content="prop/_name">name</th>
+  <td tal:content="prop/_prop">type</td>
+ </tr>
+</tal:block>
+
+</table>
+</td>
+
+</tal:block>

Added: tracker/vendor/roundup/current/templates/classic/html/home.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/html/home.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,10 @@
+<!--
+ This is the default body that is displayed when people visit the
+ tracker. The tag below lists the currently open issues. You may
+ replace it with a greeting message, or a different list of issues or
+ whatever. It's a good idea to have the issues on the front page though
+-->
+<span tal:replace="structure python:db.issue.renderWith('index',
+    sort=('-', 'activity'), group=('+', 'priority'), filter=['status'],
+    columns=['id','activity','title','creator','assignedto', 'status'],
+    filterspec={'status':['-1','1','2','3','4','5','6','7']})" />

Added: tracker/vendor/roundup/current/templates/classic/html/issue.index.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/html/issue.index.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,149 @@
+<!-- dollarId: issue.index,v 1.2 2001/07/29 04:07:37 richard Exp dollar-->
+<tal:block metal:use-macro="templates/page/macros/icing">
+<title metal:fill-slot="head_title" i18n:translate="" >
+	List of issues - 
+	<span tal:condition="request/dispname"
+          tal:replace="python:' %s - '%request.dispname" />
+	<span tal:replace="config/TRACKER_NAME" i18n:name="tracker" />
+	</title>
+<span metal:fill-slot="body_title" tal:omit-tag="python:1"
+	i18n:translate="">List of issues 
+	<span tal:condition="request/dispname"
+          tal:replace="python:' - %s' % request.dispname" />
+</span>
+<td class="content" metal:fill-slot="content">
+
+<p tal:condition="not:context/is_view_ok" i18n:translate="">You are not
+  allowed to view this page.</p>
+
+<tal:block tal:define="batch request/batch" tal:condition="context/is_view_ok">
+ <table class="list">
+  <tr>
+   <th tal:condition="request/show/priority" i18n:translate="">Priority</th>
+   <th tal:condition="request/show/id" i18n:translate="">ID</th>
+   <th tal:condition="request/show/creation" i18n:translate="">Creation</th>
+   <th tal:condition="request/show/activity" i18n:translate="">Activity</th>
+   <th tal:condition="request/show/actor" i18n:translate="">Actor</th>
+   <th tal:condition="request/show/topic" i18n:translate="">Topic</th>
+   <th tal:condition="request/show/title" i18n:translate="">Title</th>
+   <th tal:condition="request/show/status" i18n:translate="">Status</th>
+   <th tal:condition="request/show/creator" i18n:translate="">Creator</th>
+   <th tal:condition="request/show/assignedto" i18n:translate="">Assigned&nbsp;To</th>
+  </tr>
+ <tal:block tal:repeat="i batch">
+  <tr tal:define="group python:request.group[1]"
+      tal:condition="python:group and batch.propchanged(group)">
+   <th tal:attributes="colspan python:len(request.columns)"
+       tal:content="python:str(i[group]) or '(no %s set)'%group" class="group">
+   </th>
+  </tr>
+
+  <tr>
+   <td tal:condition="request/show/priority"
+       tal:content="python:i.priority.plain() or default">&nbsp;</td>
+   <td tal:condition="request/show/id" tal:content="i/id">&nbsp;</td>
+   <td class="date" tal:condition="request/show/creation"
+       tal:content="i/creation/reldate">&nbsp;</td>
+   <td class="date" tal:condition="request/show/activity"
+       tal:content="i/activity/reldate">&nbsp;</td>
+   <td class="date" tal:condition="request/show/actor"
+       tal:content="python:i.actor.plain() or default">&nbsp;</td>
+   <td tal:condition="request/show/topic"
+       tal:content="python:i.topic.plain() or default">&nbsp;</td>
+   <td tal:condition="request/show/title">
+    <a tal:attributes="href string:issue${i/id}"
+		tal:content="python:str(i.title.plain(hyperlink=0)) or '[no title]'">title</a>
+   </td>
+   <td tal:condition="request/show/status"
+       tal:content="python:i.status.plain() or default">&nbsp;</td>
+   <td tal:condition="request/show/creator"
+       tal:content="python:i.creator.plain() or default">&nbsp;</td>
+   <td tal:condition="request/show/assignedto"
+       tal:content="python:i.assignedto.plain() or default">&nbsp;</td>
+  </tr>
+
+ </tal:block>
+
+ <metal:index define-macro="batch-footer">
+ <tr tal:condition="batch">
+  <th tal:attributes="colspan python:len(request.columns)">
+   <table width="100%">
+    <tr class="navigation">
+     <th>
+      <a tal:define="prev batch/previous" tal:condition="prev"
+         tal:attributes="href python:request.indexargs_url(request.classname,
+         {'@startwith':prev.first, '@pagesize':prev.size})"
+         i18n:translate="">&lt;&lt; previous</a>
+      &nbsp;
+     </th>
+     <th i18n:translate=""><span tal:replace="batch/start" i18n:name="start"
+     />..<span tal:replace="python: batch.start + batch.length -1" i18n:name="end"
+     /> out of <span tal:replace="batch/sequence_length" i18n:name="total"
+     /></th>
+     <th>
+      <a tal:define="next batch/next" tal:condition="next"
+         tal:attributes="href python:request.indexargs_url(request.classname,
+         {'@startwith':next.first, '@pagesize':next.size})"
+         i18n:translate="">next &gt;&gt;</a>
+      &nbsp;
+     </th>
+    </tr>
+   </table>
+  </th>
+ </tr>
+ </metal:index>
+</table>
+
+<a tal:attributes="href python:request.indexargs_url('issue',
+            {'@action':'export_csv'})" i18n:translate="">Download as CSV</a>
+
+<form method="GET" class="index-controls"
+    tal:attributes="action request/classname">
+
+ <table class="form">
+  <tr tal:condition="batch">
+   <th i18n:translate="">Sort on:</th>
+   <td>
+    <select name="@sort">
+     <option value="" i18n:translate="">- nothing -</option>
+     <option tal:repeat="col context/properties"
+             tal:attributes="value col/_name;
+                             selected python:col._name == request.sort[1]"
+             tal:content="col/_name"
+             i18n:translate="">column</option>
+    </select>
+   </td>
+   <th i18n:translate="">Descending:</th>
+   <td><input type="checkbox" name="@sortdir"
+              tal:attributes="checked python:request.sort[0] == '-'">
+   </td>
+  </tr>
+  <tr>
+   <th i18n:translate="">Group on:</th>
+   <td>
+    <select name="@group">
+     <option value="" i18n:translate="">- nothing -</option>
+     <option tal:repeat="col context/properties"
+             tal:attributes="value col/_name;
+                             selected python:col._name == request.group[1]"
+             tal:content="col/_name"
+             i18n:translate="">column</option>
+    </select>
+   </td>
+   <th i18n:translate="">Descending:</th>
+   <td><input type="checkbox" name="@groupdir"
+              tal:attributes="checked python:request.group[0] == '-'">
+   </td>
+  </tr>
+  <tr><td colspan="4">
+              <input type="submit" value="Redisplay" i18n:attributes="value">
+              <tal:block tal:replace="structure
+                python:request.indexargs_form(sort=0, group=0)" />
+  </td></tr>
+ </table>
+</form>
+
+</tal:block>
+
+</td>
+</tal:block>

Added: tracker/vendor/roundup/current/templates/classic/html/issue.item.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/html/issue.item.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,190 @@
+<!-- dollarId: issue.item,v 1.4 2001/08/03 01:19:43 richard Exp dollar-->
+<tal:block metal:use-macro="templates/page/macros/icing">
+<title metal:fill-slot="head_title">
+<tal:block condition="context/id" i18n:translate=""
+ >Issue <span tal:replace="context/id" i18n:name="id"
+ />: <span tal:replace="context/title" i18n:name="title"
+ /> - <span tal:replace="config/TRACKER_NAME" i18n:name="tracker"
+/></tal:block>
+<tal:block condition="not:context/id" i18n:translate=""
+ >New Issue - <span tal:replace="config/TRACKER_NAME" i18n:name="tracker"
+/></tal:block>
+</title>
+<tal:block metal:fill-slot="body_title">
+ <span tal:condition="python: not (context.id or context.is_edit_ok())"
+  tal:omit-tag="python:1" i18n:translate="">New Issue</span>
+ <span tal:condition="python: not context.id and context.is_edit_ok()"
+  tal:omit-tag="python:1" i18n:translate="">New Issue Editing</span>
+ <span tal:condition="python: context.id and not context.is_edit_ok()"
+  tal:omit-tag="python:1" i18n:translate="">Issue<tal:x
+  replace="context/id" i18n:name="id" /></span>
+ <span tal:condition="python: context.id and context.is_edit_ok()"
+  tal:omit-tag="python:1" i18n:translate="">Issue<tal:x
+  replace="context/id" i18n:name="id" /> Editing</span>
+</tal:block>
+
+<td class="content" metal:fill-slot="content">
+
+<p tal:condition="not:context/is_view_ok" i18n:translate="">You are not
+    allowed to view this page.</p>
+
+<div tal:condition="context/is_view_ok">
+
+<form method="POST" name="itemSynopsis"
+      onSubmit="return submit_once()" enctype="multipart/form-data"
+      tal:attributes="action context/designator">
+
+<table class="form">
+<tr>
+ <th class="required" i18n:translate="">Title</th>
+ <td colspan=3 tal:content="structure python:context.title.field(size=60)">title</td>
+</tr>
+
+<tr>
+ <th class="required" i18n:translate="">Priority</th>
+ <td tal:content="structure context/priority/menu">priority</td>
+ <th i18n:translate="">Status</th>
+ <td tal:content="structure context/status/menu">status</td>
+</tr>
+
+<tr>
+ <th i18n:translate="">Superseder</th>
+ <td>
+  <span tal:replace="structure python:context.superseder.field(showid=1, size=20)" />
+  <span tal:condition="context/is_edit_ok" tal:replace="structure python:db.issue.classhelp('id,title', property='superseder')" />
+  <span tal:condition="context/superseder" tal:repeat="sup context/superseder">
+   <br><span i18n:translate="">View: <a i18n:name="link" tal:content="sup/id"
+     tal:attributes="href string:issue${sup/id}"></a></span>
+  </span>
+ </td>
+ <th i18n:translate="">Nosy List</th>
+ <td>
+  <span tal:replace="structure context/nosy/field" />
+  <span tal:condition="context/is_edit_ok" tal:replace="structure
+python:db.user.classhelp('username,realname,address', property='nosy', width='600')" /><br>
+ </td>
+</tr>
+
+<tr>
+ <th i18n:translate="">Assigned To</th>
+ <td tal:content="structure context/assignedto/menu">assignedto menu</td>
+ <th i18n:translate="">Topics</th>
+ <td>
+  <span tal:replace="structure context/topic/field" />
+  <span tal:condition="context/is_edit_ok" tal:replace="structure python:db.keyword.classhelp(property='topic')" />
+ </td>
+</tr>
+
+<tr tal:condition="context/is_edit_ok">
+ <th i18n:translate="">Change Note</th>
+ <td colspan=3>
+  <textarea tal:content="request/form/@note/value | default"
+            name="@note" wrap="hard" rows="5" cols="80"></textarea>
+ </td>
+</tr>
+
+<tr tal:condition="context/is_edit_ok">
+ <th i18n:translate="">File</th>
+ <td colspan=3><input type="file" name="@file" size="40"></td>
+</tr>
+
+<tr tal:condition="context/is_edit_ok">
+ <td>
+  &nbsp;
+  <input type="hidden" name="@template" value="item">
+  <input type="hidden" name="@required" value="title,priority">
+ </td>
+ <td colspan=3>
+  <span tal:replace="structure context/submit">submit button</span>
+  <a tal:condition="context/id" tal:attributes="href context/copy_url"
+   i18n:translate="">Make a copy</a>
+ </td>
+</tr>
+
+</table>
+</form>
+
+<tal:block tal:condition="not:context/id" i18n:translate="">
+<table class="form">
+<tr>
+ <td>Note:&nbsp;</td>
+ <th class="required">highlighted</th>
+ <td>&nbsp;fields are required.</td>
+</tr>
+</table>
+</tal:block>
+
+<p tal:condition="context/id" i18n:translate="">
+ Created on <b><tal:x replace="context/creation" i18n:name="creation" /></b>
+ by <b><tal:x replace="context/creator" i18n:name="creator" /></b>,
+ last changed <b><tal:x replace="context/activity" i18n:name="activity" /></b>
+ by <b><tal:x replace="context/actor" i18n:name="actor" /></b>.
+</p>
+
+<table class="files" tal:condition="context/files">
+ <tr><th colspan="5" class="header" i18n:translate="">Files</th></tr>
+ <tr>
+  <th i18n:translate="">File name</th>
+  <th i18n:translate="">Uploaded</th>
+  <th i18n:translate="">Type</th>
+  <th i18n:translate="">Edit</th>
+  <th i18n:translate="">Remove</th>
+ </tr>
+ <tr tal:repeat="file context/files">
+  <td>
+   <a tal:attributes="href file/download_url"
+      tal:content="file/name">dld link</a>
+  </td>
+  <td>
+   <span tal:content="file/creator">creator's name</span>,
+   <span tal:content="file/creation">creation date</span>
+  </td>
+  <td tal:content="file/type" />
+  <td><a tal:condition="file/is_edit_ok"
+          tal:attributes="href string:file${file/id}">edit</a>
+  </td>
+  <td>
+   <form style="padding:0" tal:condition="context/is_edit_ok"
+         tal:attributes="action string:issue${context/id}">
+    <input type="hidden" name="@remove at files" tal:attributes="value file/id">
+    <input type="hidden" name="@action" value="edit">
+    <input type="submit" value="remove" i18n:attributes="value">
+   </form>
+  </td>
+ </tr>
+</table>
+
+<table class="messages" tal:condition="context/messages">
+ <tr><th colspan="4" class="header" i18n:translate="">Messages</th></tr>
+ <tal:block tal:repeat="msg context/messages/reverse">
+  <tr>
+   <th><a tal:attributes="href string:msg${msg/id}"
+    i18n:translate="">msg<tal:x replace="msg/id" i18n:name="id" /> (view)</a></th>
+   <th i18n:translate="">Author: <tal:x replace="msg/author"
+       i18n:name="author" /></th>
+   <th i18n:translate="">Date: <tal:x replace="msg/date"
+       i18n:name="date" /></th>
+   <th>
+    <form style="padding:0" tal:condition="context/is_edit_ok"
+          tal:attributes="action string:issue${context/id}">
+     <input type="hidden" name="@remove at messages" tal:attributes="value msg/id">
+     <input type="hidden" name="@action" value="edit">
+     <input type="submit" value="remove" i18n:attributes="value">
+    </form>
+   </th>
+  </tr>
+  <tr>
+   <td colspan="4" class="content">
+    <pre tal:content="structure msg/content/hyperlinked">content</pre>
+   </td>
+  </tr>
+ </tal:block>
+</table>
+
+<tal:block tal:condition="context/id" tal:replace="structure context/history" />
+
+</div>
+
+</td>
+
+</tal:block>

Added: tracker/vendor/roundup/current/templates/classic/html/issue.search.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/html/issue.search.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,225 @@
+<tal:block metal:use-macro="templates/page/macros/icing">
+<title metal:fill-slot="head_title" i18n:translate="">Issue searching - <span
+ i18n:name="tracker" tal:replace="config/TRACKER_NAME" /></title>
+<span metal:fill-slot="body_title" tal:omit-tag="python:1"
+ i18n:translate="">Issue searching</span>
+<td class="content" metal:fill-slot="content">
+
+<form method="GET" name="itemSynopsis"
+      tal:attributes="action request/classname">
+
+<table class="form" tal:define="
+   cols python:request.columns or 'id activity title status assignedto'.split();
+   sort_on python:request.sort[1] or 'activity';
+   group_on python:request.group[1] or 'priority';
+
+   search_input templates/page/macros/search_input;
+   column_input templates/page/macros/column_input;
+   sort_input templates/page/macros/sort_input;
+   group_input templates/page/macros/group_input;
+   search_select templates/page/macros/search_select;
+   search_multiselect templates/page/macros/search_multiselect;">
+
+<tr>
+ <th class="header">&nbsp;</th>
+ <th class="header" i18n:translate="">Filter on</th>
+ <th class="header" i18n:translate="">Display</th>
+ <th class="header" i18n:translate="">Sort on</th>
+ <th class="header" i18n:translate="">Group on</th>
+</tr>
+
+<tr tal:define="name string:@search_text">
+  <th i18n:translate="">All text*:</th>
+  <td metal:use-macro="search_input"></td>
+  <td>&nbsp;</td>
+  <td>&nbsp;</td>
+  <td>&nbsp;</td>
+</tr>
+
+<tr tal:define="name string:title">
+  <th i18n:translate="">Title:</th>
+  <td metal:use-macro="search_input"></td>
+  <td metal:use-macro="column_input"></td>
+  <td metal:use-macro="sort_input"></td>
+  <td>&nbsp;</td>
+</tr>
+
+<tr tal:define="name string:topic;
+                db_klass string:keyword;
+                db_content string:name;">
+  <th i18n:translate="">Topic:</th>
+  <td metal:use-macro="search_select"></td>
+  <td metal:use-macro="column_input"></td>
+  <td metal:use-macro="sort_input"></td>
+  <td metal:use-macro="group_input"></td>
+</tr>
+
+<tr tal:define="name string:id">
+  <th i18n:translate="">ID:</th>
+  <td metal:use-macro="search_input"></td>
+  <td metal:use-macro="column_input"></td>
+  <td metal:use-macro="sort_input"></td>
+  <td>&nbsp;</td>
+</tr>
+
+<tr tal:define="name string:creation">
+  <th i18n:translate="">Creation Date:</th>
+  <td metal:use-macro="search_input"></td>
+  <td metal:use-macro="column_input"></td>
+  <td metal:use-macro="sort_input"></td>
+  <td metal:use-macro="group_input"></td>
+</tr>
+
+<tr tal:define="name string:creator;
+                db_klass string:user;
+                db_content string:username;"
+    tal:condition="db/user/is_view_ok">
+  <th i18n:translate="">Creator:</th>
+  <td metal:use-macro="search_select">
+    <option metal:fill-slot="extra_options" i18n:translate=""
+            tal:attributes="value request/user/id">created by me</option>
+  </td>
+  <td metal:use-macro="column_input"></td>
+  <td metal:use-macro="sort_input"></td>
+  <td metal:use-macro="group_input"></td>
+</tr>
+
+<tr tal:define="name string:activity">
+  <th i18n:translate="">Activity:</th>
+  <td metal:use-macro="search_input"></td>
+  <td metal:use-macro="column_input"></td>
+  <td metal:use-macro="sort_input"></td>
+  <td>&nbsp;</td>
+</tr>
+
+<tr tal:define="name string:actor;
+                db_klass string:user;
+                db_content string:username;"
+    tal:condition="db/user/is_view_ok">
+  <th i18n:translate="">Actor:</th>
+  <td metal:use-macro="search_select">
+    <option metal:fill-slot="extra_options" i18n:translate=""
+            tal:attributes="value request/user/id">done by me</option>
+  </td>
+  <td metal:use-macro="column_input"></td>
+  <td metal:use-macro="sort_input"></td>
+  <td>&nbsp;</td>
+</tr>
+
+<tr tal:define="name string:priority;
+                db_klass string:priority;
+                db_content string:name;">
+  <th i18n:translate="">Priority:</th>
+  <td metal:use-macro="search_select">
+    <option metal:fill-slot="extra_options" value="-1" i18n:translate=""
+            tal:attributes="selected python:value == '-1'">not selected</option>
+  </td>
+  <td metal:use-macro="column_input"></td>
+  <td metal:use-macro="sort_input"></td>
+  <td metal:use-macro="group_input"></td>
+</tr>
+
+<tr tal:define="name string:status;
+                db_klass string:status;
+                db_content string:name;">
+  <th i18n:translate="">Status:</th>
+  <td metal:use-macro="search_select">
+    <tal:block metal:fill-slot="extra_options">
+      <option value="-1,1,2,3,4,5,6,7" i18n:translate=""
+              tal:attributes="selected python:value == '-1,1,2,3,4,5,6,7'">not resolved</option>
+      <option value="-1" i18n:translate=""
+              tal:attributes="selected python:value == '-1'">not selected</option>
+    </tal:block>
+  </td>
+  <td metal:use-macro="column_input"></td>
+  <td metal:use-macro="sort_input"></td>
+  <td metal:use-macro="group_input"></td>
+</tr>
+
+<tr tal:define="name string:assignedto;
+                db_klass string:user;
+                db_content string:username;"
+    tal:condition="db/user/is_view_ok">
+  <th i18n:translate="">Assigned to:</th>
+  <td metal:use-macro="search_select">
+    <tal:block metal:fill-slot="extra_options">
+      <option tal:attributes="value request/user/id"
+       i18n:translate="">assigned to me</option>
+      <option value="-1" tal:attributes="selected python:value == '-1'"
+       i18n:translate="">unassigned</option>
+    </tal:block>
+  </td>
+  <td metal:use-macro="column_input"></td>
+  <td metal:use-macro="sort_input"></td>
+  <td metal:use-macro="group_input"></td>
+</tr>
+
+<tr>
+ <th i18n:translate="">No Sort or group:</th>
+ <td>&nbsp;</td>
+ <td>&nbsp;</td>
+ <td><input type="radio" name="@sort" value=""></td>
+ <td><input type="radio" name="@group" value=""></td>
+</tr>
+
+<tr>
+<th i18n:translate="">Pagesize:</th>
+<td><input name="@pagesize" size="3" value="50"
+           tal:attributes="value request/form/@pagesize/value | default"></td>
+</tr>
+
+<tr>
+<th i18n:translate="">Start With:</th>
+<td><input name="@startwith" size="3" value="0"
+           tal:attributes="value request/form/@startwith/value | default"></td>
+</tr>
+
+<tr>
+<th i18n:translate="">Sort Descending:</th>
+<td><input type="checkbox" name="@sortdir"
+           tal:attributes="checked python:request.sort[0] == '-' or request.sort[0] is None">
+</td>
+</tr>
+
+<tr>
+<th i18n:translate="">Group Descending:</th>
+<td><input type="checkbox" name="@groupdir"
+           tal:attributes="checked python:request.group[0] == '-'">
+</td>
+</tr>
+
+<tr tal:condition="python:request.user.hasPermission('Edit', 'query')">
+ <th i18n:translate="">Query name**:</th>
+ <td tal:define="value request/form/@queryname/value | nothing">
+  <input name="@queryname" tal:attributes="value value">
+  <input type="hidden" name="@old-queryname" tal:attributes="value value">
+ </td>
+</tr>
+
+<tr>
+  <td>
+   &nbsp;
+   <input type="hidden" name="@action" value="search">
+  </td>
+  <td><input type="submit" value="Search" i18n:attributes="value"></td>
+</tr>
+
+<tr><td>&nbsp;</td>
+ <td colspan="4" class="help">
+  <span i18n:translate="" tal:omit-tag="true">
+   *: The "all text" field will look in message bodies and issue titles
+  </span><br>
+  <span tal:condition="python:request.user.hasPermission('Edit', 'query')"
+   i18n:translate="" tal:omit-tag="true"
+  >
+   **: If you supply a name, the query will be saved off and available as a
+       link in the sidebar
+  </span>
+ </td>
+</tr>
+</table>
+
+</form>
+</td>
+
+</tal:block>

Added: tracker/vendor/roundup/current/templates/classic/html/keyword.item.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/html/keyword.item.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,55 @@
+<!-- dollarId: keyword.item,v 1.3 2002/05/22 00:32:34 richard Exp dollar-->
+<tal:block metal:use-macro="templates/page/macros/icing">
+<title metal:fill-slot="head_title" i18n:translate="">Keyword editing - <span
+ i18n:name="tracker" tal:replace="config/TRACKER_NAME" /></title>
+<span metal:fill-slot="body_title" tal:omit-tag="python:1"
+ i18n:translate="">Keyword editing</span>
+<td class="content" metal:fill-slot="content">
+
+<table class="otherinfo" tal:define="keywords db/keyword/list"
+       tal:condition="keywords">
+ <tr><th colspan="4" class="header" i18n:translate="">Existing Keywords</th></tr>
+ <tr tal:repeat="start python:range(0, len(keywords), 4)">
+  <td width="25%" tal:define="batch python:utils.Batch(keywords, 4, start)"
+      tal:repeat="keyword batch">
+    <a tal:attributes="href string:keyword${keyword/id}"
+       tal:content="keyword/name">keyword here</a>
+  </td>
+ </tr>
+ <tr>
+  <td colspan="4" style="border-top: 1px solid gray" i18n:translate="">
+   To edit an existing keyword (for spelling or typing errors),
+   click on its entry above.
+  </td>
+ </tr>
+</table>
+
+<p class="help" tal:condition="not:context/id" i18n:translate="">
+ To create a new keyword, enter it below and click "Submit New Entry".
+</p>
+
+<form method="POST" onSubmit="return submit_once()"
+      enctype="multipart/form-data"
+      tal:attributes="action context/designator">
+
+ <table class="form">
+  <tr>
+   <th i18n:translate="">Keyword</th>
+   <td tal:content="structure context/name/field">name</td>
+  </tr>
+
+  <tr>
+   <td>
+    &nbsp;
+    <input type="hidden" name="@required" value="name">
+    <input type="hidden" name="@template" value="item">
+   </td>
+   <td colspan=3 tal:content="structure context/submit">
+    submit button will go here
+   </td>
+  </tr>
+ </table>
+</form>
+</td>
+
+</tal:block>

Added: tracker/vendor/roundup/current/templates/classic/html/msg.index.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/html/msg.index.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,25 @@
+<tal:block metal:use-macro="templates/page/macros/icing">
+<title metal:fill-slot="head_title" i18n:translate=""
+ >List of messages - <span tal:replace="config/TRACKER_NAME"
+ i18n:name="tracker"/></title>
+<span metal:fill-slot="body_title" tal:omit-tag="python:1"
+ i18n:translate="">Message listing</span>
+<td class="content" metal:fill-slot="content">
+<table tal:define="batch request/batch" class="messages">
+ <tr><th colspan=2 class="header" i18n:translate="">Messages</th></tr>
+ <tal:block tal:repeat="msg batch">
+  <tr>
+   <th tal:content="string:Author: ${msg/author}">author</th>
+   <th tal:content="string:Date: ${msg/date}">date</th>
+  </tr>
+  <tr>
+   <td colspan="2"><pre tal:content="msg/content">content</pre></td>
+  </tr>
+ </tal:block>
+
+ <metal:block use-macro="templates/issue.index/macros/batch-footer" />
+
+</table>
+</td>
+
+</tal:block>

Added: tracker/vendor/roundup/current/templates/classic/html/msg.item.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/html/msg.item.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,78 @@
+<!-- dollarId: msg.item,v 1.3 2002/05/22 00:32:34 richard Exp dollar-->
+<tal:block metal:use-macro="templates/page/macros/icing">
+<title metal:fill-slot="head_title">
+<tal:block condition="context/id" i18n:translate=""
+ >Message <span tal:replace="context/id" i18n:name="id"
+ /> - <span tal:replace="config/TRACKER_NAME" i18n:name="tracker"
+/></tal:block>
+<tal:block condition="not:context/id" i18n:translate=""
+ >New Message - <span tal:replace="config/TRACKER_NAME" i18n:name="tracker"
+/></tal:block>
+</title>
+<tal:block metal:fill-slot="body_title">
+ <span tal:condition="python: not (context.id or context.is_edit_ok())"
+  tal:omit-tag="python:1" i18n:translate="">New Message</span>
+ <span tal:condition="python: not context.id and context.is_edit_ok()"
+  tal:omit-tag="python:1" i18n:translate="">New Message Editing</span>
+ <span tal:condition="python: context.id and not context.is_edit_ok()"
+  tal:omit-tag="python:1" i18n:translate="">Message<tal:x
+  replace="context/id" i18n:name="id" /></span>
+ <span tal:condition="python: context.id and context.is_edit_ok()"
+  tal:omit-tag="python:1" i18n:translate="">Message<tal:x
+  replace="context/id" i18n:name="id" /> Editing</span>
+</tal:block>
+<td class="content" metal:fill-slot="content">
+
+<p tal:condition="not:context/is_view_ok" i18n:translate="">You are not
+    allowed to view this page.</p>
+
+<div tal:condition="context/is_view_ok">
+<table class="form">
+
+<tr>
+ <th i18n:translate="">Author</th>
+ <td tal:content="context/author"></td>
+</tr>
+
+<tr>
+ <th i18n:translate="">Recipients</th>
+ <td tal:content="context/recipients"></td>
+</tr>
+
+<tr>
+ <th i18n:translate="">Date</th>
+ <td tal:content="context/date"></td>
+</tr>
+</table>
+
+<table class="messages">
+ <tr><th colspan=2 class="header" i18n:translate="">Content</th></tr>
+ <tr>
+  <td class="content" colspan=2><pre tal:content="structure context/content/hyperlinked"></pre></td>
+ </tr>
+</table>
+
+<table class="files" tal:condition="context/files">
+ <tr><th colspan="2" class="header" i18n:translate="">Files</th></tr>
+ <tr>
+  <th i18n:translate="">File name</th>
+  <th i18n:translate="">Uploaded</th>
+ </tr>
+ <tr tal:repeat="file context/files">
+  <td>
+   <a tal:attributes="href string:file${file/id}/${file/name}"
+      tal:content="file/name">dld link</a>
+  </td>
+  <td>
+   <span tal:content="file/creator">creator's name</span>,
+   <span tal:content="file/creation">creation date</span>
+  </td>
+ </tr>
+</table>
+
+<tal:block tal:replace="structure context/history" />
+
+</div>
+</td>
+
+</tal:block>

Added: tracker/vendor/roundup/current/templates/classic/html/page.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/html/page.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,232 @@
+<tal:block metal:define-macro="icing">
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
+                               "http://www.w3.org/TR/html4/strict.dtd">
+<html>
+<head>
+<title metal:define-slot="head_title">title goes here</title>
+<link rel="stylesheet" type="text/css" href="@@file/style.css">
+<meta http-equiv="Content-Type"
+ tal:attributes="content string:text/html;; charset=${request/client/charset}" />
+<script tal:replace="structure request/base_javascript">
+</script>
+
+</head>
+<body class="body">
+
+<table class="body">
+
+<tr>
+ <td class="page-header-left">&nbsp;</td>
+ <td class="page-header-top">
+   <div id="body-title">
+     <h2><span metal:define-slot="body_title">body title</span></h2>
+   </div>
+   <div id="searchbox">
+     <form method="GET" action="issue">
+       <input type="hidden" name="@columns"
+              value="id,activity,title,creator,assignedto,status"/>
+       <input type="hidden" name="@sort" value="activity"/>
+       <input type="hidden" name="@group" value="priority"/>
+       <input id="search-text" name="@search_text" size="10"/>
+       <input type="submit" id="submit" name="submit" value="Search" i18n:attributes="value"/>
+     </form>
+  </div>
+ </td>
+</tr>
+
+<tr>
+ <td rowspan="2" valign="top" class="sidebar">
+  <p class="classblock"
+     tal:condition="python:request.user.hasPermission('View', 'query')">
+   <span i18n:translate=""
+    ><b>Your Queries</b> (<a href="query?@template=edit">edit</a>)</span><br>
+   <tal:block tal:repeat="qs request/user/queries">
+    <a tal:attributes="href string:${qs/klass}?${qs/url}&@dispname=${qs/name}"
+       tal:content="qs/name">link</a><br>
+   </tal:block>
+  </p>
+
+  <form method="POST" tal:attributes="action request/base">
+   <p class="classblock"
+       tal:condition="python:request.user.hasPermission('View', 'issue')">
+    <b i18n:translate="">Issues</b><br>
+    <span tal:condition="python:request.user.hasPermission('Create', 'issue')">
+      <a href="issue?@template=item" i18n:translate="">Create New</a><br>
+    </span>
+    <a href="issue?@sort=-activity&@group=priority&@filter=status,assignedto&@columns=id,activity,title,creator,status&status=-1,1,2,3,4,5,6,7&assignedto=-1&@dispname=Show%20Unassigned"
+     i18n:translate="">Show Unassigned</a><br>
+    <a href="issue?@sort=-activity&@group=priority&@filter=status&@columns=id,activity,title,creator,assignedto,status&status=-1,1,2,3,4,5,6,7&@dispname=Show%20All"
+     i18n:translate="">Show All</a><br>
+    <a href="issue?@template=search" i18n:translate="">Search</a><br>
+    <input type="submit" class="form-small" value="Show issue:"
+     i18n:attributes="value"><input class="form-small" size="4"
+     type="text" name="@number">
+    <input type="hidden" name="@type" value="issue">
+    <input type="hidden" name="@action" value="show">
+   </p>
+  </form>
+
+  <p class="classblock"
+     tal:condition="python:request.user.hasPermission('Edit', 'keyword')
+        or request.user.hasPermission('Create', 'keyword')">
+   <b i18n:translate="">Keywords</b><br>
+   <span tal:condition="python:request.user.hasPermission('Create', 'keyword')">
+    <a href="keyword?@template=item" i18n:translate="">Create New</a><br>
+   </span>
+   <span tal:condition="python:db.keyword.list() and
+        request.user.hasPermission('Edit', 'keyword')">
+    <a href="keyword?@template=item" i18n:translate="">Edit Existing</a><br>
+   </span>
+  </p>
+
+  <p class="classblock"
+       tal:condition="python:request.user.hasPermission('View', 'user')">
+   <b i18n:translate="">Administration</b><br>
+   <span tal:condition="python:request.user.hasPermission('Edit', None)">
+    <a href="home?@template=classlist" i18n:translate="">Class List</a><br>
+   </span>
+   <span tal:condition="python:request.user.hasPermission('View', 'user')
+                            or request.user.hasPermission('Edit', 'user')">
+    <a href="user"  i18n:translate="">User List</a><br>
+   </span>
+   <a tal:condition="python:request.user.hasPermission('Create', 'user')"
+      href="user?@template=item" i18n:translate="">Add User</a>
+  </p>
+
+  <form method="POST" tal:condition="python:request.user.username=='anonymous'"
+        tal:attributes="action request/base">
+   <p class="userblock">
+    <b i18n:translate="">Login</b><br>
+    <input size="10" name="__login_name"><br>
+    <input size="10" type="password" name="__login_password"><br>
+    <input type="hidden" name="@action" value="Login">
+    <input type="checkbox" name="remember" id="remember">
+    <label for="remember" i18n:translate="">Remember me?</label><br>
+    <input type="submit" value="Login" i18n:attributes="value"><br>
+    <input type="hidden" name="__came_from" tal:attributes="value string:${request/base}${request/env/PATH_INFO}">
+    <span tal:replace="structure request/indexargs_form" />
+    <a href="user?@template=register"
+       tal:condition="python:request.user.hasPermission('Create', 'user')"
+     i18n:translate="">Register</a><br>
+    <a href="user?@template=forgotten" i18n:translate="">Lost&nbsp;your&nbsp;login?</a><br>
+   </p>
+  </form>
+
+  <p class="userblock" tal:condition="python:request.user.username != 'anonymous'">
+   <b i18n:translate="">Hello, <span i18n:name="user"
+    tal:replace="request/user/username">username</span></b><br>
+   <a tal:attributes="href string:issue?@sort=-activity&@group=priority&@filter=status,assignedto&@columns=id,activity,title,creator,status&status=-1,1,2,3,4,5,6,7&assignedto=${request/user/id}" i18n:translate="">Your Issues</a><br>
+   <a tal:attributes="href string:user${request/user/id}"
+    i18n:translate="">Your Details</a><br>
+   <a tal:attributes="href python:request.indexargs_url('',
+       {'@action':'logout'})" i18n:translate="">Logout</a>
+  </p>
+  <p class="userblock">
+   <b i18n:translate="">Help</b><br>
+   <a href="http://roundup.sourceforge.net/doc-1.0/"
+    i18n:translate="">Roundup docs</a>
+  </p>
+ </td>
+ <td>
+  <p tal:condition="options/error_message | nothing" class="error-message"
+     tal:repeat="m options/error_message" tal:content="structure m" />
+  <p tal:condition="options/ok_message | nothing" class="ok-message">
+    <span tal:repeat="m options/ok_message"
+       tal:content="structure string:$m <br/ > " />
+     <a class="form-small" tal:attributes="href request/current_url"
+        i18n:translate="">clear this message</a>
+  </p>
+ </td>
+</tr>
+<tr>
+ <td class="content" metal:define-slot="content">Page content goes here</td>
+</tr>
+
+</table>
+
+<pre tal:condition="request/form/debug | nothing" tal:content="request">
+</pre>
+
+</body>
+</html>
+</tal:block>
+
+<!--
+The following macros are intended to be used in search pages.
+
+The invoking context must define a "name" variable which names the
+property being searched.
+
+See issue.search.html in the classic template for examples.
+-->
+<td metal:define-macro="search_input">
+  <input tal:attributes="value python:request.form.getvalue(name) or nothing;
+                         name name">
+</td>
+
+<td metal:define-macro="search_popup">
+  <!--
+    context needs to specify the popup "columns" as a comma-separated
+    string (eg. "id,title" or "id,name,description") as well as name
+  -->
+  <input tal:attributes="value python:request.form.getvalue(name) or nothing;
+                         name name">
+  <span tal:replace="structure python:db.issue.classhelp(columns,
+                                      property=name)" />
+</td>
+
+<td metal:define-macro="search_select">
+  <select tal:attributes="name name"
+          tal:define="value python:request.form.getvalue(name)">
+    <option value="" i18n:translate="">don't care</option>
+    <tal:block metal:define-slot="extra_options"></tal:block>
+    <option value="" i18n:translate="">------------</option>
+    <option tal:repeat="s python:db[db_klass].list()"
+            tal:attributes="value s/id; selected python:value == s.id"
+            tal:content="python:s[db_content]"></option>
+  </select>
+</td>
+
+<td metal:define-macro="search_multiselect">
+  <input tal:attributes="value python:request.form.getvalue(name) or nothing;
+                         name name">
+  <span tal:replace="structure python:db[db_klass].classhelp(db_content,
+                                        property=name, width='600')" />
+</td>
+
+<td metal:define-macro="search_checkboxes">
+ <ul class="search-checkboxes"
+     tal:define="value python:request.form.getvalue(name);
+                 values python:value and value.split(',') or []">
+ <li tal:repeat="s python:db[db_klass].list()">
+  <input type="checkbox" tal:attributes="name name; id string:$name-${s/id};
+    value s/id; checked python:s.id in values" />
+  <label tal:attributes="for string:$name-${s/id}"
+         tal:content="python:s[db_content]" />
+ </li>
+ <li metal:define-slot="no_value_item">
+  <input type="checkbox" value="-1" tal:attributes="name name;
+     id string:$name--1; checked python:value == '-1'" />
+  <label tal:attributes="for string:$name--1" i18n:translate="">no value</label>
+ </li>
+ </ul>
+</td>
+
+<td metal:define-macro="column_input">
+  <input type="checkbox" name="@columns"
+         tal:attributes="value name;
+                         checked python:name in cols">
+</td>
+
+<td metal:define-macro="sort_input">
+  <input type="radio" name="@sort"
+         tal:attributes="value name;
+                         checked python:name == sort_on">
+</td>
+
+<td metal:define-macro="group_input">
+  <input type="radio" name="@group"
+         tal:attributes="value name;
+                         checked python:name == group_on">
+</td>
+

Added: tracker/vendor/roundup/current/templates/classic/html/query.edit.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/html/query.edit.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,109 @@
+<!-- dollarId: user.item,v 1.7 2002/08/16 04:29:04 richard Exp dollar-->
+<tal:block metal:use-macro="templates/page/macros/icing">
+<title metal:fill-slot="head_title" i18n:translate=""
+ >"Your Queries" Editing - <span tal:replace="config/TRACKER_NAME"
+ i18n:name="tracker" /></title>
+<span metal:fill-slot="body_title" tal:omit-tag="python:1"
+ i18n:translate="">"Your Queries" Editing</span>
+
+<td class="content" metal:fill-slot="content">
+
+<span tal:condition="not:context/is_edit_ok"
+ i18n:translate="">You are not allowed to edit queries.</span>
+
+<script language="javascript">
+// This exists solely because I can't figure how to get the & into an
+// attributes TALES expression, and so it keeps getting quoted.
+function retire(qid) {
+    window.location = 'query'+qid+'?@action=retire&@template=edit';
+}
+</script>
+
+<form method="POST" onSubmit="return submit_once()" action="query"
+      enctype="multipart/form-data" tal:condition="context/is_edit_ok">
+
+<table class="list" width="100%"
+       tal:define="uid request/user/id; mine request/user/queries">
+
+<tr><th i18n:translate="">Query</th>
+    <th i18n:translate="">Include in "Your Queries"</th>
+    <th i18n:translate="">Edit</th>
+    <th i18n:translate="">Private to you?</th>
+    <th>&nbsp;</th>
+</tr>
+
+<tr tal:repeat="query mine">
+ <tal:block condition="query/is_retired">
+
+ <td><a tal:attributes="href string:${query/klass}?${query/url}"
+        tal:content="query/name">query</a></td>
+
+ <td metal:define-macro="include">
+  <select tal:condition="python:query.id not in mine"
+          tal:attributes="name string:user${uid}@add at queries">
+    <option value="" i18n:translate="">leave out</option>
+    <option tal:attributes="value query/id" i18n:translate="">include</option>
+  </select>
+  <select tal:condition="python:query.id in mine"
+          tal:attributes="name string:user${uid}@remove at queries">
+    <option value="" i18n:translate="">leave in</option>
+    <option tal:attributes="value query/id" i18n:translate="">remove</option>
+  </select>
+ </td>
+
+ <td colspan="3" i18n:translate="">[query is retired]</td>
+
+ <!-- <td> maybe offer "restore" some day </td> -->
+ </tal:block>
+</tr>
+
+<tr tal:define="queries python:db.query.filter(filterspec={'private_for':uid})"
+     tal:repeat="query queries">
+ <td><a tal:attributes="href string:${query/klass}?${query/url}"
+        tal:content="query/name">query</a></td>
+
+ <td metal:use-macro="template/macros/include" />
+
+ <td><a tal:attributes="href string:query${query/id}" i18n:translate="">edit</a></td>
+
+ <td>
+  <select tal:attributes="name string:query${query/id}@private_for">
+   <option tal:attributes="selected python:query.private_for == uid;
+           value uid" i18n:translate="">yes</option>
+   <option tal:attributes="selected python:query.private_for == None"
+           value="-1" i18n:translate="">no</option>
+  </select>
+ </td>
+
+ <td>
+  <input type="button" value="Delete" i18n:attributes="value"
+  tal:attributes="onClick python:'''retire('%s')'''%query.id">
+  </td>
+</tr>
+
+<tr tal:define="queries python:db.query.filter(filterspec={'private_for':None})"
+     tal:repeat="query queries">
+ <td><a tal:attributes="href string:${query/klass}?${query/url}"
+        tal:content="query/name">query</a></td>
+
+ <td metal:use-macro="template/macros/include" />
+
+ <td colspan="3" tal:condition="query/is_edit_ok">
+  <a tal:attributes="href string:query${query/id}" i18n:translate="">edit</a>
+ </td>
+ <td tal:condition="not:query/is_edit_ok" colspan="3"
+    i18n:translate="">[not yours to edit]</td>
+
+</tr>
+
+<tr><td colspan="5">
+   <input type="hidden" name="@action" value="edit">
+   <input type="hidden" name="@template" value="edit">
+   <input type="submit" value="Save Selection" i18n:attributes="value">
+</td></tr>
+
+</table>
+
+</form>
+</td>
+</tal:block>

Added: tracker/vendor/roundup/current/templates/classic/html/query.item.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/html/query.item.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,3 @@
+<!-- query.item -->
+<span tal:replace="structure context/renderQueryForm" />
+

Added: tracker/vendor/roundup/current/templates/classic/html/style.css
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/html/style.css	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,423 @@
+/* main page styles */
+body.body {
+  font-family: sans-serif, Arial, Helvetica;
+  background-color: white;
+  color: #333;
+  margin: 0;
+}
+a[href]:hover {
+  color:blue;
+  text-decoration: underline;
+}
+a[href], a[href]:link {
+  color:blue;
+  text-decoration: none;
+}
+
+table.body {
+  border: 0;
+  padding: 0;
+  border-spacing: 0;
+  border-collapse: separate;
+}
+
+td.page-header-left {
+  padding: 5px;
+  border-bottom: 1px solid #444;
+}
+td.sidebar {
+  padding: 1px 0 0 1px;
+  white-space: nowrap;
+}
+
+/* don't display the sidebar when printing */
+ at media print {
+    td.page-header-left {
+        display: none;
+    }
+    td.sidebar {
+        display: none;
+    }
+    .index-controls {
+        display: none;
+    }
+    #searchbox {
+        display: none;
+    }
+}
+
+td.page-header-top {
+  padding: 5px;
+  border-bottom: 1px solid #444;
+}
+#searchbox {
+    float: right;
+}
+
+div#body-title {
+  float: left;
+}
+
+
+div#searchbox {
+  float: right;
+  padding-top: 1em;
+}
+
+div#searchbox input#search-text {
+  width: 10em;
+}
+
+form {
+  margin: 0;
+}
+
+textarea {
+    font-family: monospace;
+}
+
+td.sidebar p.classblock {
+  padding: 2px 5px 2px 5px;
+  margin: 1px;
+  border: 1px solid #444;
+  background-color: #eee;
+}
+
+td.sidebar p.userblock {
+  padding: 2px 5px 2px 5px;
+  margin: 1px 1px 1px 1px;
+  border: 1px solid #444;
+  background-color: #eef;
+}
+
+.form-small {
+  padding: 0;
+  font-size: 75%;
+}
+
+
+td.content {
+  padding: 1px 5px 1px 5px;
+  vertical-align: top;
+  width: 100%;
+}
+
+td.date, th.date { 
+  white-space: nowrap;
+}
+
+p.ok-message {
+  background-color: #22bb22;
+  padding: 5px;
+  color: white;
+  font-weight: bold;
+}
+p.error-message {
+  background-color: #bb2222;
+  padding: 5px;
+  color: white;
+  font-weight: bold;
+}
+p.error-message a[href] {
+  color: white;
+  text-decoration: underline;
+}
+
+
+/* style for search forms */
+ul.search-checkboxes {
+    display: inline;
+    padding: none;
+    list-style: none;
+}
+ul.search-checkboxes > li {
+    display: inline;
+    padding-right: .5em;
+}
+
+
+/* style for forms */
+table.form {
+  padding: 2px;
+  border-spacing: 0;
+  border-collapse: separate;
+}
+
+table.form th {
+  color: #338;
+  text-align: right;
+  vertical-align: top;
+  font-weight: normal;
+  white-space: nowrap;
+}
+
+table.form th.header {
+  font-weight: bold;
+  background-color: #eef;
+  text-align: left;
+}
+
+table.form th.required {
+  font-weight: bold;
+}
+
+table.form td {
+  color: #333;
+  empty-cells: show;
+  vertical-align: top;
+}
+
+table.form td.optional {
+  font-weight: bold;
+  font-style: italic;
+}
+
+table.form td.html {
+  color: #777;
+}
+
+/* style for lists */
+table.list {
+  border-spacing: 0;
+  border-collapse: separate;
+  width: 100%;
+}
+
+table.list th {
+  padding: 0 4px 0 4px;
+  color: #404070;
+  background-color: #eef;
+  border: 1px solid white;
+  vertical-align: top;
+  empty-cells: show;
+}
+table.list th a[href]:hover { color: #404070 }
+table.list th a[href]:link { color: #404070 }
+table.list th a[href] { color: #404070 }
+table.list th.group {
+  background-color: #f4f4ff;
+  text-align: center;
+}
+
+table.list td {
+  padding: 0 4px 0 4px;
+  border: 1px solid white;
+  color: #404070;
+  background-color: #efefef;
+  vertical-align: top;
+  empty-cells: show;
+}
+
+table.list tr.navigation th {
+  width: 33%;
+  border-style: hidden;
+  text-align: center;
+}
+table.list tr.navigation td {
+    border: none
+}
+table.list tr.navigation th:first-child {
+  text-align: left;
+}
+table.list tr.navigation th:last-child {
+  text-align: right;
+}
+
+
+/* style for message displays */
+table.messages {
+  border-spacing: 0;
+  border-collapse: separate;
+  width: 100%;
+}
+
+table.messages th.header{
+  padding-top: 10px;
+  border-bottom: 1px solid gray;
+  font-weight: bold;
+  background-color: white;
+  color: #707040;
+}
+
+table.messages th {
+  font-weight: bold;
+  color: black;
+  text-align: left;
+  border-bottom: 1px solid #afafaf;
+}
+
+table.messages td {
+  font-family: monospace;
+  background-color: #efefef;
+  border-bottom: 1px solid #afafaf;
+  color: black;
+  empty-cells: show;
+  border-right: 1px solid #afafaf;
+  vertical-align: top;
+  padding: 2px 5px 2px 5px;
+}
+
+table.messages td:first-child {
+  border-left: 1px solid #afafaf;
+  border-right: 1px solid #afafaf;
+}
+
+/* style for file displays */
+table.files {
+  border-spacing: 0;
+  border-collapse: separate;
+  width: 100%;
+}
+
+table.files th.header{
+  padding-top: 10px;
+  border-bottom: 1px solid gray;
+  font-weight: bold;
+  background-color: white;
+  color: #707040;
+}
+
+table.files th {
+  border-bottom: 1px solid #afafaf;
+  font-weight: bold;
+  text-align: left;
+}
+
+table.files td {
+  font-family: monospace;
+  empty-cells: show;
+}
+
+/* style for history displays */
+table.history {
+  border-spacing: 0;
+  border-collapse: separate;
+  width: 100%;
+}
+
+table.history th.header{
+  padding-top: 10px;
+  border-bottom: 1px solid gray;
+  font-weight: bold;
+  background-color: white;
+  color: #707040;
+  font-size: 100%;
+}
+
+table.history th {
+  border-bottom: 1px solid #afafaf;
+  font-weight: bold;
+  text-align: left;
+  font-size: 90%;
+}
+
+table.history td {
+  font-size: 90%;
+  vertical-align: top;
+  empty-cells: show;
+}
+
+
+/* style for class list */
+table.classlist {
+  border-spacing: 0;
+  border-collapse: separate;
+  width: 100%;
+}
+
+table.classlist th.header{
+  padding-top: 10px;
+  border-bottom: 1px solid gray;
+  font-weight: bold;
+  background-color: white;
+  color: #707040;
+}
+
+table.classlist th {
+  font-weight: bold;
+  text-align: left;
+}
+
+
+/* style for class help display */
+table.classhelp {      /* the table-layout: fixed;        */ 
+  table-layout: fixed; /* compromises quality for speed   */
+  overflow: hidden;
+  font-size: .9em;
+  padding-bottom: 3em;
+}
+
+table.classhelp th {
+  font-weight: normal;
+  text-align: left;
+  color: #444;
+  background-color: #efefef;
+  border-bottom: 1px solid #afafaf;
+  border-top: 1px solid #afafaf;
+  text-transform: uppercase;
+  vertical-align: middle;
+  line-height:1.5em;
+}
+
+table.classhelp td {
+  vertical-align: middle;
+  padding-right: .2em;
+  border-bottom: 1px solid #efefef;
+  text-align: left;
+  empty-cells: show;
+  white-space: nowrap;
+  vertical-align: middle;
+}
+
+table.classhelp tr:hover {
+  background-color: #eee;
+}
+
+label.classhelp-label {
+  cursor: pointer;
+}
+
+#classhelp-controls {
+  position: fixed;
+  display: block;
+  top: auto;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  padding: .5em;
+  border-top: 2px solid #444;
+  background-color: #eee;
+}
+
+#classhelp-controls input.apply {
+  width: 7em;
+  font-weight: bold;
+  margin-right: 2em;
+  margin-left: 2em;
+}
+
+#classhelp-controls input.preview {
+   margin-right: 3em;
+   margin-left: 1em;
+}
+
+/* style for "other" displays */
+table.otherinfo {
+  border-spacing: 0;
+  border-collapse: separate;
+  width: 100%;
+}
+
+table.otherinfo th.header{
+  padding-top: 10px;
+  border-bottom: 1px solid gray;
+  font-weight: bold;
+  background-color: white;
+  color: #707040;
+}
+
+table.otherinfo th {
+  border-bottom: 1px solid #afafaf;
+  font-weight: bold;
+  text-align: left;
+}

Added: tracker/vendor/roundup/current/templates/classic/html/user.forgotten.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/html/user.forgotten.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,43 @@
+<!-- dollarId: user.item,v 1.7 2002/08/16 04:29:04 richard Exp dollar-->
+<tal:block metal:use-macro="templates/page/macros/icing">
+<title metal:fill-slot="head_title" i18n:translate="">Password reset request - <span
+ i18n:name="tracker" tal:replace="config/TRACKER_NAME" /></title>
+<span metal:fill-slot="body_title" tal:omit-tag="python:1"
+ i18n:translate="">Password reset request</span>
+<td class="content" metal:fill-slot="content">
+
+<p i18n:translate="">You have two options if you have forgotten your password.
+If you know the email address you registered with, enter it below.</p>
+
+<form method="POST" onSubmit="return submit_once()"
+      tal:attributes="action context/designator">
+    <table class="form">
+      <tr>
+        <th i18n:translate="">Email Address:</th>
+        <td><input name="address"></td>
+      </tr>
+      <tr>
+        <td>&nbsp;</td>
+        <td>
+          <input type="hidden" name="@action" value="passrst">
+          <input type="hidden" name="@template" value="forgotten">
+          <input type="submit" value="Request password reset"
+           i18n:attributes="value">
+        </td>
+      </tr>
+</table>
+
+<p i18n:translate="">Or, if you know your username, then enter it below.</p>
+
+<table class="form">
+ <tr><th i18n:translate="">Username:</th> <td><input name="username"></td> </tr>
+ <tr><td></td><td><input type="submit" value="Request password reset"
+   i18n:attributes="value"></td></tr>
+</table>
+</form>
+
+<p i18n:translate="">A confirmation email will be sent to you -
+please follow the instructions within it to complete the reset process.</p>
+</td>
+
+</tal:block>

Added: tracker/vendor/roundup/current/templates/classic/html/user.index.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/html/user.index.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,40 @@
+<!-- dollarId: user.index,v 1.3 2002/07/09 05:29:51 richard Exp dollar-->
+<tal:block metal:use-macro="templates/page/macros/icing">
+<title metal:fill-slot="head_title" i18n:translate="">User listing - <span
+ i18n:name="tracker" tal:replace="config/TRACKER_NAME" /></title>
+<span metal:fill-slot="body_title" tal:omit-tag="python:1"
+ i18n:translate="">User listing</span>
+<td class="content" metal:fill-slot="content">
+
+<span tal:condition="not:context/is_view_ok"
+ i18n:translate="">You are not allowed to view this page.</span>
+
+<table width="100%" tal:condition="context/is_view_ok" class="list">
+<tr>
+ <th i18n:translate="">Username</th>
+ <th i18n:translate="">Real name</th>
+ <th i18n:translate="">Organisation</th>
+ <th i18n:translate="">Email address</th>
+ <th i18n:translate="">Phone number</th>
+ <th tal:condition="context/is_edit_ok" i18n:translate="">Retire</th>
+</tr>
+<tal:block repeat="user context/list">
+<tr tal:attributes="class python:['normal', 'alt'][repeat['user'].index%6/3]">
+ <td>
+  <a tal:attributes="href string:user${user/id}"
+     tal:content="user/username">username</a>
+ </td>
+ <td tal:content="python:user.realname.plain() or default">&nbsp;</td>
+ <td tal:content="python:user.organisation.plain() or default">&nbsp;</td>
+ <td tal:content="python:user.address.email() or default">&nbsp;</td>
+ <td tal:content="python:user.phone.plain() or default">&nbsp;</td>
+ <td tal:condition="context/is_edit_ok">
+  <a tal:attributes="href string:user${user/id}?@action=retire&@template=index"
+   i18n:translate="">retire</a>
+ </td>
+</tr>
+</tal:block>
+</table>
+</td>
+
+</tal:block>

Added: tracker/vendor/roundup/current/templates/classic/html/user.item.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/html/user.item.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,122 @@
+<!-- dollarId: user.item,v 1.7 2002/08/16 04:29:04 richard Exp dollar-->
+<tal:block metal:use-macro="templates/page/macros/icing">
+<title metal:fill-slot="head_title">
+<tal:block condition="context/id" i18n:translate=""
+ >User <span tal:replace="context/id" i18n:name="id"
+ />: <span tal:replace="context/username" i18n:name="title"
+ /> - <span tal:replace="config/TRACKER_NAME" i18n:name="tracker"
+/></tal:block>
+<tal:block condition="not:context/id" i18n:translate=""
+ >New User - <span tal:replace="config/TRACKER_NAME" i18n:name="tracker"
+/></tal:block>
+</title>
+<tal:block metal:fill-slot="body_title">
+ <span tal:condition="python: not (context.id or context.is_edit_ok())"
+  tal:omit-tag="python:1" i18n:translate="">New User</span>
+ <span tal:condition="python: not context.id and context.is_edit_ok()"
+  tal:omit-tag="python:1" i18n:translate="">New User Editing</span>
+ <span tal:condition="python: context.id and not context.is_edit_ok()"
+  tal:omit-tag="python:1" i18n:translate="">User<tal:x
+  replace="context/id" i18n:name="id" /></span>
+ <span tal:condition="python: context.id and context.is_edit_ok()"
+  tal:omit-tag="python:1" i18n:translate="">User<tal:x
+  replace="context/id" i18n:name="id" /> Editing</span>
+</tal:block>
+
+<td class="content" metal:fill-slot="content">
+
+<p tal:condition="not:context/is_view_ok" i18n:translate="">You are not
+    allowed to view this page.</p>
+
+<div tal:condition="context/is_view_ok">
+
+<form method="POST" onSubmit="return submit_once()"
+      enctype="multipart/form-data"
+      tal:attributes="action context/designator">
+
+<table class="form">
+ <tr>
+  <th i18n:translate="">Name</th>
+  <td tal:content="structure context/realname/field">realname</td>
+ </tr>
+ <tr>
+  <th class="required" i18n:translate="">Login Name</th>
+  <td tal:content="structure context/username/field">username</td>
+ </tr>
+ <tr tal:condition="context/is_edit_ok">
+  <th i18n:translate="">Login Password</th>
+  <td tal:content="structure context/password/field">password</td>
+ </tr>
+ <tr tal:condition="context/is_edit_ok">
+  <th i18n:translate="">Confirm Password</th>
+  <td tal:content="structure context/password/confirm">password</td>
+ </tr>
+ <tr tal:condition="python:request.user.hasPermission('Web Roles')">
+  <th i18n:translate="">Roles</th>
+  <td>
+   <input tal:condition="context/id"
+          tal:replace="structure context/roles/field">
+   <input name="roles" tal:condition="not:context/id"
+          tal:attributes="value db/config/NEW_WEB_USER_ROLES">
+   <tal:block i18n:translate="">(to give the user more than one role,
+    enter a comma,separated,list)</tal:block>
+  </td>
+ </tr>
+ <tr>
+  <th i18n:translate="">Phone</th>
+  <td tal:content="structure context/phone/field">phone</td>
+ </tr>
+ <tr>
+  <th i18n:translate="">Organisation</th>
+  <td tal:content="structure context/organisation/field">organisation</td>
+ </tr>
+ <tr>
+  <th i18n:translate="">Timezone</th>
+  <td>
+   <input tal:replace="structure context/timezone/field">
+   <tal:block i18n:translate="">(this is a numeric hour offset, the default is
+    <span tal:replace="db/config/DEFAULT_TIMEZONE" i18n:name="zone"
+    />)</tal:block>
+  </td>
+ </tr>
+ <tr>
+  <th class="required" i18n:translate="">E-mail address</th>
+  <td tal:define="mailto context/address/field">
+   <a tal:condition="not:context/is_edit_ok"
+    tal:attributes="href string:mailto:${mailto}" tal:content="mailto"
+   /><span tal:condition="context/is_edit_ok" tal:replace="structure mailto" />
+  </td>
+ </tr>
+ <tr>
+  <th i18n:translate="">Alternate E-mail addresses<br>One address per line</th>
+  <td tal:content="structure context/alternate_addresses/multiline">alternate_addresses</td>
+ </tr>
+
+ <tr tal:condition="context/is_edit_ok">
+  <td>
+   &nbsp;
+   <input type="hidden" name="@template" value="item">
+   <input type="hidden" name="@required" value="username,address">
+  </td>
+  <td tal:content="structure context/submit">submit button here</td>
+ </tr>
+</table>
+</form>
+
+<tal:block tal:condition="not:context/id" i18n:translate="">
+<table class="form">
+<tr>
+ <td>Note:&nbsp;</td>
+ <th class="required">highlighted</th>
+ <td>&nbsp;fields are required.</td>
+</tr>
+</table>
+</tal:block>
+
+<tal:block tal:condition="context/id" tal:replace="structure context/history" />
+
+</div>
+
+</td>
+
+</tal:block>

Added: tracker/vendor/roundup/current/templates/classic/html/user.register.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/html/user.register.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,81 @@
+<!-- dollarId: user.item,v 1.7 2002/08/16 04:29:04 richard Exp dollar-->
+<tal:block metal:use-macro="templates/page/macros/icing">
+<title metal:fill-slot="head_title"
+ i18n:translate="">Registering with <span i18n:name="tracker"
+ tal:replace="db/config/TRACKER_NAME" /></title>
+<span metal:fill-slot="body_title" tal:omit-tag="python:1"
+ i18n:translate="">Registering with <span i18n:name="tracker"
+ tal:replace="db/config/TRACKER_NAME" /></span>
+<td class="content" metal:fill-slot="content">
+
+<form method="POST" onSubmit="return submit_once()"
+      enctype="multipart/form-data"
+      tal:attributes="action context/designator">
+
+<table class="form">
+ <tr>
+  <th i18n:translate="">Name</th>
+  <td tal:content="structure context/realname/field">realname</td>
+ </tr>
+ <tr>
+  <th class="required" i18n:translate="">Login Name</th>
+  <td tal:content="structure context/username/field">username</td>
+ </tr>
+ <tr>
+  <th class="required" i18n:translate="">Login Password</th>
+  <td tal:content="structure context/password/field">password</td>
+ </tr>
+ <tr>
+  <th class="required" i18n:translate="">Confirm Password</th>
+  <td tal:content="structure context/password/confirm">password</td>
+ </tr>
+ <tr tal:condition="python:request.user.hasPermission('Web Roles')">
+  <th i18n:translate="">Roles</th>
+  <td tal:condition="exists:item"
+      tal:content="structure context/roles/field">roles</td>
+  <td tal:condition="not:exists:item">
+   <input name="roles" tal:attributes="value db/config/NEW_WEB_USER_ROLES">
+  </td>
+ </tr>
+ <tr>
+  <th i18n:translate="">Phone</th>
+  <td tal:content="structure context/phone/field">phone</td>
+ </tr>
+ <tr>
+  <th i18n:translate="">Organisation</th>
+  <td tal:content="structure context/organisation/field">organisation</td>
+ </tr>
+ <tr>
+  <th class="required" i18n:translate="">E-mail address</th>
+  <td tal:content="structure context/address/field">address</td>
+ </tr>
+ <tr>
+  <th i18n:translate="">Alternate E-mail addresses<br>One address per line</th>
+  <td tal:content="structure context/alternate_addresses/multiline">alternate_addresses</td>
+ </tr>
+
+ <tr>
+  <td>&nbsp;</td>
+  <td>
+   <input type="hidden" name="@template" value="register">
+   <input type="hidden" name="@required" value="username,password,address">
+   <input type="hidden" name="@action" value="register">
+   <input type="submit" name="submit" value="Register" i18n:attributes="value">
+  </td>
+ </tr>
+</table>
+</form>
+
+<tal:block tal:condition="not:context/id" i18n:translate="">
+<table class="form">
+<tr>
+ <td>Note:&nbsp;</td>
+ <th class="required">highlighted</th>
+ <td>&nbsp;fields are required.</td>
+</tr>
+</table>
+</tal:block>
+
+</td>
+
+</tal:block>

Added: tracker/vendor/roundup/current/templates/classic/html/user.rego_progress.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/html/user.rego_progress.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,16 @@
+<!-- dollarId: issue.index,v 1.2 2001/07/29 04:07:37 richard Exp dollar-->
+<tal:block metal:use-macro="templates/page/macros/icing">
+<title metal:fill-slot="head_title"
+ i18n:translate="">Registration in progress - <span i18n:name="tracker"
+ tal:replace="config/TRACKER_NAME" /></title>
+<span metal:fill-slot="body_title" tal:omit-tag="python:1"
+ i18n:translate="">Registration in progress...</span>
+<td class="content" metal:fill-slot="content">
+
+<p i18n:translate="">You will shortly receive an email
+to confirm your registration. To complete the registration process,
+visit the link indicated in the email.
+</p>
+
+</td>
+</tal:block>

Added: tracker/vendor/roundup/current/templates/classic/initial_data.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/initial_data.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,32 @@
+#
+# TRACKER INITIAL PRIORITY AND STATUS VALUES
+#
+pri = db.getclass('priority')
+pri.create(name="critical", order="1")
+pri.create(name="urgent", order="2")
+pri.create(name="bug", order="3")
+pri.create(name="feature", order="4")
+pri.create(name="wish", order="5")
+
+stat = db.getclass('status')
+stat.create(name="unread", order="1")
+stat.create(name="deferred", order="2")
+stat.create(name="chatting", order="3")
+stat.create(name="need-eg", order="4")
+stat.create(name="in-progress", order="5")
+stat.create(name="testing", order="6")
+stat.create(name="done-cbb", order="7")
+stat.create(name="resolved", order="8")
+
+# create the two default users
+user = db.getclass('user')
+user.create(username="admin", password=adminpw,
+    address=admin_email, roles='Admin')
+user.create(username="anonymous", roles='Anonymous')
+
+# add any additional database creation steps here - but only if you
+# haven't initialised the database with the admin "initialise" command
+
+
+# vim: set filetype=python sts=4 sw=4 et si
+#SHA: b1da2e72a7fe9f26086f243eb744135b085101d9

Added: tracker/vendor/roundup/current/templates/classic/schema.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/classic/schema.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,169 @@
+
+#
+# TRACKER SCHEMA
+#
+
+# Class automatically gets these properties:
+#   creation = Date()
+#   activity = Date()
+#   creator = Link('user')
+#   actor = Link('user')
+
+# Priorities
+pri = Class(db, "priority",
+                name=String(),
+                order=Number())
+pri.setkey("name")
+
+# Statuses
+stat = Class(db, "status",
+                name=String(),
+                order=Number())
+stat.setkey("name")
+
+# Keywords
+keyword = Class(db, "keyword",
+                name=String())
+keyword.setkey("name")
+
+# User-defined saved searches
+query = Class(db, "query",
+                klass=String(),
+                name=String(),
+                url=String(),
+                private_for=Link('user'))
+
+# add any additional database schema configuration here
+
+user = Class(db, "user",
+                username=String(),
+                password=Password(),
+                address=String(),
+                realname=String(),
+                phone=String(),
+                organisation=String(),
+                alternate_addresses=String(),
+                queries=Multilink('query'),
+                roles=String(),     # comma-separated string of Role names
+                timezone=String())
+user.setkey("username")
+
+# FileClass automatically gets this property in addition to the Class ones:
+#   content = String()    [saved to disk in <tracker home>/db/files/]
+#   type = String()       [MIME type of the content, default 'text/plain']
+msg = FileClass(db, "msg",
+                author=Link("user", do_journal='no'),
+                recipients=Multilink("user", do_journal='no'),
+                date=Date(),
+                summary=String(),
+                files=Multilink("file"),
+                messageid=String(),
+                inreplyto=String())
+
+file = FileClass(db, "file",
+                name=String())
+
+# IssueClass automatically gets these properties in addition to the Class ones:
+#   title = String()
+#   messages = Multilink("msg")
+#   files = Multilink("file")
+#   nosy = Multilink("user")
+#   superseder = Multilink("issue")
+issue = IssueClass(db, "issue",
+                assignedto=Link("user"),
+                topic=Multilink("keyword"),
+                priority=Link("priority"),
+                status=Link("status"))
+
+#
+# TRACKER SECURITY SETTINGS
+#
+# See the configuration and customisation document for information
+# about security setup.
+
+#
+# REGULAR USERS
+#
+# Give the regular users access to the web and email interface
+db.security.addPermissionToRole('User', 'Web Access')
+db.security.addPermissionToRole('User', 'Email Access')
+
+# Assign the access and edit Permissions for issue, file and message
+# to regular users now
+for cl in 'issue', 'file', 'msg', 'keyword':
+    db.security.addPermissionToRole('User', 'View', cl)
+    db.security.addPermissionToRole('User', 'Edit', cl)
+    db.security.addPermissionToRole('User', 'Create', cl)
+for cl in 'priority', 'status':
+    db.security.addPermissionToRole('User', 'View', cl)
+
+# May users view other user information? Comment these lines out
+# if you don't want them to
+db.security.addPermissionToRole('User', 'View', 'user')
+
+# Users should be able to edit their own details -- this permission is
+# limited to only the situation where the Viewed or Edited item is their own.
+def own_record(db, userid, itemid):
+    '''Determine whether the userid matches the item being accessed.'''
+    return userid == itemid
+p = db.security.addPermission(name='View', klass='user', check=own_record,
+    description="User is allowed to view their own user details")
+db.security.addPermissionToRole('User', p)
+p = db.security.addPermission(name='Edit', klass='user', check=own_record,
+    description="User is allowed to edit their own user details")
+db.security.addPermissionToRole('User', p)
+
+# Users should be able to edit and view their own queries. They should also
+# be able to view any marked as not private. They should not be able to
+# edit others' queries, even if they're not private
+def view_query(db, userid, itemid):
+    private_for = db.query.get(itemid, 'private_for')
+    if not private_for: return True
+    return userid == private_for
+def edit_query(db, userid, itemid):
+    return userid == db.query.get(itemid, 'creator')
+p = db.security.addPermission(name='View', klass='query', check=view_query,
+    description="User is allowed to view their own and public queries")
+db.security.addPermissionToRole('User', p)
+p = db.security.addPermission(name='Edit', klass='query', check=edit_query,
+    description="User is allowed to edit their queries")
+db.security.addPermissionToRole('User', p)
+p = db.security.addPermission(name='Create', klass='query',
+    description="User is allowed to create queries")
+db.security.addPermissionToRole('User', p)
+
+
+#
+# ANONYMOUS USER PERMISSIONS
+#
+# Let anonymous users access the web interface. Note that almost all
+# trackers will need this Permission. The only situation where it's not
+# required is in a tracker that uses an HTTP Basic Authenticated front-end.
+db.security.addPermissionToRole('Anonymous', 'Web Access')
+
+# Let anonymous users access the email interface (note that this implies
+# that they will be registered automatically, hence they will need the
+# "Create" user Permission below)
+# This is disabled by default to stop spam from auto-registering users on
+# public trackers.
+#db.security.addPermissionToRole('Anonymous', 'Email Access')
+
+# Assign the appropriate permissions to the anonymous user's Anonymous
+# Role. Choices here are:
+# - Allow anonymous users to register
+db.security.addPermissionToRole('Anonymous', 'Create', 'user')
+
+# Allow anonymous users access to view issues (and the related, linked
+# information)
+for cl in 'issue', 'file', 'msg', 'keyword', 'priority', 'status':
+    db.security.addPermissionToRole('Anonymous', 'View', cl)
+
+# [OPTIONAL]
+# Allow anonymous users access to create or edit "issue" items (and the
+# related file and message items)
+#for cl in 'issue', 'file', 'msg':
+#   db.security.addPermissionToRole('Anonymous', 'Create', cl)
+#   db.security.addPermissionToRole('Anonymous', 'Edit', cl)
+
+
+# vim: set filetype=python sts=4 sw=4 et si :

Added: tracker/vendor/roundup/current/templates/minimal/.cvsignore
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/minimal/.cvsignore	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,4 @@
+*.pyc
+*.pyo
+htmlbase.py
+*.cover

Added: tracker/vendor/roundup/current/templates/minimal/TEMPLATE-INFO.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/minimal/TEMPLATE-INFO.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,8 @@
+Name: minimal
+Description: This is an empty tracker - it must be customised for it to be
+             useful! It only defines the bare minimum of information - the
+             user database and the two default users (admin and anonymous).
+             The rest is entirely up to you! Not recommended for first-time
+             Roundup users (it's easier to tweak the Classic tracker).
+Intended-For: Roundup experts who need a clean slate to start with.
+

Added: tracker/vendor/roundup/current/templates/minimal/detectors/.cvsignore
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/minimal/detectors/.cvsignore	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,3 @@
+*.pyc
+*.pyo
+*.cover

Added: tracker/vendor/roundup/current/templates/minimal/detectors/userauditor.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/minimal/detectors/userauditor.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,44 @@
+# Copyright (c) 2003 Richard Jones (richard at mechanicalcat.net)
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+#   The above copyright notice and this permission notice shall be included in
+#   all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+#
+#$Id: userauditor.py,v 1.2 2003/11/11 22:25:37 richard Exp $
+
+def audit_user_fields(db, cl, nodeid, newvalues):
+    ''' Make sure user properties are valid.
+
+        - email address has no spaces in it
+        - roles specified exist
+    '''
+    if newvalues.has_key('address') and ' ' in newvalues['address']:
+        raise ValueError, 'Email address must not contain spaces'
+
+    if newvalues.has_key('roles'):
+        roles = [x.lower().strip() for x in newvalues['roles'].split(',')]
+        for rolename in roles:
+            if not db.security.role.has_key(rolename):
+                raise ValueError, 'Role "%s" does not exist'%rolename
+
+
+def init(db):
+    # fire before changes are made
+    db.user.audit('set', audit_user_fields)
+    db.user.audit('create', audit_user_fields)
+
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/templates/minimal/extensions/README.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/minimal/extensions/README.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,6 @@
+This directory is for tracker extensions:
+
+- CGI Actions
+- Templating functions
+
+See the customisation doc for more information.

Added: tracker/vendor/roundup/current/templates/minimal/html/_generic.calendar.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/minimal/html/_generic.calendar.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,18 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html>
+ <head>
+  <link rel="stylesheet" type="text/css" href="@@file/style.css" />
+  <meta http-equiv="Content-Type" content="text/html; charset=utf-8;" />
+  <title tal:content="string:Roundup Calendar"></title>
+  <script language="Javascript"
+          type="text/javascript"
+          tal:content="structure string:
+          // this is the name of the field in the original form that we're working on
+          form  = window.opener.document.${request/form/form/value};
+          field = '${request/form/property/value}';" >
+  </script>
+ </head>
+ <body class="body"
+       tal:content="structure python:utils.html_calendar (request)">
+ </body>
+</html>

Added: tracker/vendor/roundup/current/templates/minimal/html/_generic.collision.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/minimal/html/_generic.collision.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,16 @@
+<tal:block metal:use-macro="templates/page/macros/icing">
+<title metal:fill-slot="head_title" i18n:translate=""
+ ><span tal:replace="python:context._classname.capitalize()"
+ i18n:name="class" /> Edit Collision - <span i18n:name="tracker"
+ tal:replace="config/TRACKER_NAME" /></title>
+<tal:block metal:fill-slot="body_title" i18n:translate=""
+ ><span tal:replace="python:context._classname.capitalize()"
+ i18n:name="class" /> Edit Collision</tal:block>
+
+<td class="content" metal:fill-slot="content" i18n:translate="
+  There has been a collision. Another user updated this node
+  while you were editing. Please <a href='${context}'>reload</a>
+  the node and review your edits.
+"><span tal:replace="context/designator" i18n:name="context" />
+</td>
+</tal:block>

Added: tracker/vendor/roundup/current/templates/minimal/html/_generic.help.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/minimal/html/_generic.help.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,98 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html tal:define="property request/form/property/value" >
+  <head>
+      <link rel="stylesheet" type="text/css" href="@@file/style.css" />
+      <meta http-equiv="Content-Type"
+       tal:attributes="content string:text/html;; charset=${request/client/charset}" />
+      <tal:block tal:condition="python:request.form.has_key('property')">
+      <title i18n:translate=""><tal:x i18n:name="property"
+       tal:content="property" i18n:translate="" /> help - <span i18n:name="tracker"
+       tal:replace="config/TRACKER_NAME" /></title>
+      <script language="Javascript" type="text/javascript"
+          tal:content="structure string:
+          // this is the name of the field in the original form that we're working on
+          form  = window.opener.document.${request/form/form/value};
+          field  = '${request/form/property/value}';">
+      </script>
+      <script src="@@file/help_controls.js" type="text/javascript"><!--
+      //--></script>
+      </tal:block>
+  </head>
+ <body class="body" onload="resetList();">
+ <form name="frm_help" tal:attributes="action request/base"
+       tal:define="batch request/batch;
+                   props python:request.form['properties'].value.split(',')">
+
+     <div id="classhelp-controls">
+       <!--input type="button" name="btn_clear"
+              value="Clear" onClick="clearList()"/ -->
+       <input type="text" name="text_preview" size="24" class="preview"
+              onchange="reviseList(this.value);"/>
+       <input type="button" name="btn_reset"
+              value=" Cancel " onclick="resetList(); window.close();"
+              i18n:attributes="value" />
+       <input type="button" name="btn_apply" class="apply"
+              value=" Apply " onclick="updateList(); window.close();"
+              i18n:attributes="value" />
+     </div>
+     <table width="100%">
+      <tr class="navigation">
+       <th>
+        <a tal:define="prev batch/previous" tal:condition="prev"
+           tal:attributes="href python:request.indexargs_url(request.classname,
+           {'@template':'help', 'property': request.form['property'].value,
+            'properties': request.form['properties'].value,
+            'form': request.form['form'].value,
+            'type': request.form['type'].value,
+            '@startwith':prev.first, '@pagesize':prev.size})"
+           i18n:translate="" >&lt;&lt; previous</a>
+        &nbsp;
+       </th>
+       <th i18n:translate=""><span tal:replace="batch/start" i18n:name="start"
+        />..<span tal:replace="python: batch.start + batch.length -1" i18n:name="end"
+        /> out of <span tal:replace="batch/sequence_length" i18n:name="total"
+        />
+       </th>
+       <th>
+        <a tal:define="next batch/next" tal:condition="next"
+           tal:attributes="href python:request.indexargs_url(request.classname,
+           {'@template':'help', 'property': request.form['property'].value,
+            'properties': request.form['properties'].value,
+            'form': request.form['form'].value,
+            'type': request.form['type'].value,
+            '@startwith':next.first, '@pagesize':next.size})"
+           i18n:translate="" >next &gt;&gt;</a>
+        &nbsp;
+       </th>
+      </tr>
+     </table>
+
+     <table class="classhelp">
+       <tr>
+           <th>&nbsp;<b>x</b></th>
+           <th tal:repeat="prop props" tal:content="prop" i18n:translate=""></th>
+       </tr>
+       <tr tal:repeat="item batch">
+         <tal:block tal:define="attr python:item[props[0]]" >
+           <td>
+             <input name="check"
+                 onclick="updatePreview();"
+                 tal:attributes="type python:request.form['type'].value;
+                                 value attr; id string:id_$attr" />
+             </td>
+             <td tal:repeat="prop props">
+                 <label class="classhelp-label"
+                        tal:attributes="for string:id_$attr"
+                        tal:content="structure python:item[prop]"></label>
+             </td>
+           </tal:block>
+       </tr>
+       <tr>
+           <th>&nbsp;<b>x</b></th>
+           <th tal:repeat="prop props" tal:content="prop" i18n:translate=""></th>
+       </tr>
+     </table>
+
+ </form>
+ </body>
+</html>

Added: tracker/vendor/roundup/current/templates/minimal/html/_generic.index.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/minimal/html/_generic.index.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,64 @@
+<!-- dollarId: issue.index,v 1.2 2001/07/29 04:07:37 richard Exp dollar-->
+
+<tal:block metal:use-macro="templates/page/macros/icing">
+<title metal:fill-slot="head_title" i18n:translate=""
+ ><span tal:replace="python:context._classname.capitalize()"
+ i18n:name="class" /> editing - <span i18n:name="tracker"
+ tal:replace="config/TRACKER_NAME" /></title>
+<tal:block metal:fill-slot="body_title" i18n:translate=""
+ ><span tal:replace="python:context._classname.capitalize()"
+ i18n:name="class" /> editing</tal:block>
+
+<td class="content" metal:fill-slot="content">
+
+<span tal:condition="python:not (context.is_view_ok() or context.is_edit_ok())"
+ tal:omit-tag="python:1" i18n:translate=""
+>You are not allowed to view this page.</span>
+
+<tal:block tal:condition="context/is_edit_ok">
+<tal:block i18n:translate="">
+<p class="form-help">
+ You may edit the contents of the
+ <span tal:replace="request/classname" i18n:name="classname"/>
+ class using this form. Commas, newlines and double quotes (") must be
+ handled delicately. You may include commas and newlines by enclosing the
+ values in double-quotes ("). Double quotes themselves must be quoted by
+ doubling ("").
+</p>
+
+<p class="form-help">
+ Multilink properties have their multiple values colon (":") separated
+ (... ,"one:two:three", ...)
+</p>
+
+<p class="form-help">
+ Remove entries by deleting their line. Add new entries by appending
+ them to the table - put an X in the id column.
+</p>
+</tal:block>
+<form onSubmit="return submit_once()" method="POST"
+      tal:attributes="action context/designator">
+<textarea rows="15" style="width:90%" name="rows" tal:content="context/csv"></textarea>
+<br>
+<input type="hidden" name="@action" value="editCSV">
+<input type="submit" value="Edit Items" i18n:attributes="value">
+</form>
+</tal:block>
+
+<table tal:condition="context/is_only_view_ok" width="100%" class="list">
+ <tr>
+  <th tal:repeat="property context/propnames" tal:content="property">&nbsp;</th>
+ </tr>
+ <tal:block repeat="item context/list">
+ <tr tal:condition="item/is_view_ok"
+     tal:attributes="class python:['normal', 'alt'][repeat['item'].index%6/3]">
+  <td tal:repeat="property context/propnames"
+   tal:content="python: item[property] or default"
+  >&nbsp;</td>
+  </tr>
+ </tal:block>
+</table>
+
+</td>
+
+</tal:block>

Added: tracker/vendor/roundup/current/templates/minimal/html/_generic.item.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/minimal/html/_generic.item.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,59 @@
+<tal:block metal:use-macro="templates/page/macros/icing">
+<title metal:fill-slot="head_title" i18n:translate=""
+ ><span tal:replace="python:context._classname.capitalize()"
+ i18n:name="class" /> editing - <span i18n:name="tracker"
+ tal:replace="config/TRACKER_NAME" /></title>
+<tal:block metal:fill-slot="body_title" i18n:translate=""
+ ><span tal:replace="python:context._classname.capitalize()"
+ i18n:name="class" /> editing</tal:block>
+
+<td class="content" metal:fill-slot="content">
+
+<span tal:condition="python:not (context.is_view_ok() or context.is_edit_ok())"
+ tal:omit-tag="python:1" i18n:translate=""
+>You are not allowed to view this page.</span>
+
+<form method="POST" onSubmit="return submit_once()"
+      enctype="multipart/form-data" tal:condition="context/is_edit_ok"
+      tal:attributes="action context/designator">
+
+<input type="hidden" name="@template" value="item">
+
+<table class="form">
+
+<tr tal:repeat="prop python:db[context._classname].properties()">
+ <tal:block tal:condition="python:prop._name not in ('id',
+   'creator', 'creation', 'actor', 'activity')">
+  <th tal:content="prop/_name"></th>
+  <td tal:content="structure python:context[prop._name].field()"></td>
+ </tal:block>
+</tr>
+<tr>
+ <td>&nbsp;</td>
+ <td colspan=3 tal:content="structure context/submit">
+  submit button will go here
+ </td>
+</tr>
+</table>
+
+</form>
+
+<table class="form" tal:condition="context/is_only_view_ok">
+
+<tr tal:repeat="prop python:db[context._classname].properties()">
+ <tal:block tal:condition="python:prop._name not in ('id', 'creator',
+                                  'creation', 'activity')">
+  <th tal:content="prop/_name"></th>
+  <td tal:content="structure python:context[prop._name].field()"></td>
+ </tal:block>
+</tr>
+</table>
+
+
+<tal:block tal:condition="python:context.id and context.is_view_ok()">
+ <tal:block tal:replace="structure context/history" />
+</tal:block>
+
+</td>
+
+</tal:block>

Added: tracker/vendor/roundup/current/templates/minimal/html/help_controls.js
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/minimal/html/help_controls.js	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,111 @@
+// initial values for either Nosy, Superseder, Topic and Waiting On,
+// depending on which has called
+original_field = form[field].value;
+
+// Some browsers (ok, IE) don't define the "undefined" variable.
+undefined = document.geez_IE_is_really_friggin_annoying;
+
+function trim(value) {
+  var temp = value;
+  var obj = /^(\s*)([\W\w]*)(\b\s*$)/;
+  if (obj.test(temp)) { temp = temp.replace(obj, '$2'); }
+  var obj = /  /g;
+  while (temp.match(obj)) { temp = temp.replace(obj, " "); }
+  return temp;
+}
+
+function determineList() {
+    // generate a comma-separated list of the checked items
+    var list = new String('');
+    for (box=0; box < document.frm_help.check.length; box++) {
+        if (document.frm_help.check[box].checked) {
+            if (list.length == 0) {
+                separator = '';
+            }
+            else {
+                separator = ',';
+            }
+            // we used to use an Array and push / join, but IE5.0 sux
+            list = list + separator + document.frm_help.check[box].value;
+        }
+    }
+    return list;
+}
+
+function updateList() {
+  // write back to opener window
+  if (document.frm_help.check==undefined) { return; }
+  form[field].value = determineList();
+}
+
+function updatePreview() {
+  // update the preview box
+  if (document.frm_help.check==undefined) { return; }
+  writePreview(determineList());
+}
+
+function clearList() {
+  // uncheck all checkboxes
+  if (document.frm_help.check==undefined) { return; }
+  for (box=0; box < document.frm_help.check.length; box++) {
+      document.frm_help.check[box].checked = false;
+  }
+}
+
+function reviseList(vals) {
+  // update the checkboxes based on the preview field
+  if (document.frm_help.check==undefined) { return; }
+  var to_check;
+  var list = vals.split(",");
+  if (document.frm_help.check.length==undefined) {
+      check = document.frm_help.check;
+      to_check = false;
+      for (val in list) {
+          if (check.value==trim(list[val])) {
+              to_check = true;
+              break;
+          }
+      }
+      check.checked = to_check;
+  } else {
+    for (box=0; box < document.frm_help.check.length; box++) {
+      check = document.frm_help.check[box];
+      to_check = false;
+      for (val in list) {
+          if (check.value==trim(list[val])) {
+              to_check = true;
+              break;
+          }
+      }
+      check.checked = to_check;
+    }
+  }
+}
+
+function resetList() {
+  // reset preview and check boxes to initial values
+  if (document.frm_help.check==undefined) { return; }
+  writePreview(original_field);
+  reviseList(original_field);
+}
+
+function writePreview(val) {
+   // writes a value to the text_preview
+   document.frm_help.text_preview.value = val;
+}
+
+function focusField(name) {
+    for(i=0; i < document.forms.length; ++i) {
+      var obj = document.forms[i].elements[name];
+      if (obj && obj.focus) {obj.focus();}
+    }
+}
+
+function selectField(name) {
+    for(i=0; i < document.forms.length; ++i) {
+      var obj = document.forms[i].elements[name];
+      if (obj && obj.focus){obj.focus();} 
+      if (obj && obj.select){obj.select();}
+    }
+}
+

Added: tracker/vendor/roundup/current/templates/minimal/html/home.classlist.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/minimal/html/home.classlist.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,25 @@
+<tal:block metal:use-macro="templates/page/macros/icing">
+<title metal:fill-slot="head_title" i18n:translate="">List of classes - <span
+ i18n:name="tracker" tal:replace="config/TRACKER_NAME" /></title>
+<span metal:fill-slot="body_title" tal:omit-tag="python:1"
+ i18n:translate="">List of classes</span>
+<td class="content" metal:fill-slot="content">
+<table class="classlist">
+
+<tal:block tal:repeat="cl db/classes">
+ <tr>
+  <th class="header" colspan="2" align="left">
+   <a tal:attributes="href string:${cl/classname}"
+      tal:content="python:cl.classname.capitalize()">classname</a>
+  </th>
+ </tr>
+ <tr tal:repeat="prop cl/properties">
+  <th tal:content="prop/_name">name</th>
+  <td tal:content="prop/_prop">type</td>
+ </tr>
+</tal:block>
+
+</table>
+</td>
+
+</tal:block>

Added: tracker/vendor/roundup/current/templates/minimal/html/home.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/minimal/html/home.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,25 @@
+<tal:block metal:use-macro="templates/page/macros/icing">
+<title metal:fill-slot="head_title" i18n:translate="">Tracker home - <span
+ i18n:name="tracker" tal:replace="config/TRACKER_NAME" /></title>
+<span metal:fill-slot="body_title" tal:omit-tag="python:1"
+ i18n:translate="">Tracker home</span>
+<td class="content" metal:fill-slot="content">
+
+<!--
+ This is the default body that is displayed when people visit the
+ tracker. The tag below lists the currently open issues. You may
+ replace it with a greeting message, or a different list of issues or
+ whatever. It's a good idea to have the issues on the front page though
+-->
+
+<tal:block tal:define="anon python:request.user.username == 'anonymous'">
+<p tal:condition="not:anon" class="help" i18n:translate="">
+Please select from one of the menu options on the left.
+</p>
+<p tal:condition="anon" class="help" i18n:translate="">
+Please log in or register.
+</p>
+</tal:block>
+
+</td>
+</tal:block>

Added: tracker/vendor/roundup/current/templates/minimal/html/page.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/minimal/html/page.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,97 @@
+<tal:block metal:define-macro="icing">
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
+                               "http://www.w3.org/TR/html4/strict.dtd">
+<html>
+<head>
+<title metal:define-slot="head_title">title goes here</title>
+<link rel="stylesheet" type="text/css" href="@@file/style.css">
+<meta http-equiv="Content-Type"
+ tal:attributes="content string:text/html;; charset=${request/client/charset}" />
+<script tal:replace="structure request/base_javascript">
+</script>
+
+</head>
+<body class="body">
+
+<table class="body">
+
+<tr>
+ <td class="page-header-left">&nbsp;</td>
+ <td class="page-header-top">
+   <div id="body-title">
+     <h2><span metal:define-slot="body_title">body title</span></h2>
+   </div>
+   <div id="searchbox">
+     <form method="GET" action="issue">
+       <input type="hidden" name="@columns"
+              value="id,activity,title,creator,assignedto,status"/>
+       <input type="hidden" name="@sort" value="activity"/>
+       <input type="hidden" name="@group" value="priority"/>
+       <input id="search-text" name="@search_text" size="10"/>
+       <input type="submit" id="submit" name="submit" value="Search" i18n:attributes="value"/>
+     </form>
+  </div>
+ </td>
+</tr>
+
+<tr>
+ <td rowspan="2" valign="top" class="sidebar">
+  <p class="userblock" tal:condition="python:request.user.username=='anonymous'">
+   <form method="POST" action="">
+    <input size="10" name="__login_name"><br>
+    <input size="10" type="password" name="__login_password"><br>
+    <input type="hidden" name="@action" value="Login">
+    <input type="checkbox" name="remember" id="remember">
+    <label for="remember" i18n:translate="">Remember me?</label><br>
+    <input type="submit" value="Login" i18n:attributes="value">
+    <input type="hidden" name="__came_from" tal:attributes="value string:${request/base}${request/env/PATH_INFO}">
+    <span tal:replace="structure request/indexargs_form" />
+   </form>
+   <a tal:condition="python:request.user.hasPermission('Web Registration')"
+      href="user?@template=register" i18n:translate="">Register</a>
+  </p>
+
+  <p class="userblock" tal:condition="python:request.user.username != 'anonymous'">
+   <b i18n:translate="">Hello,<br><span i18n:name="user"
+    tal:content="request/user/username">username</span></b><br>
+   <a tal:attributes="href string:user${request/user/id}"
+    i18n:translate="">Your Details</a><br>
+   <a tal:attributes="href python:request.indexargs_url('',
+       {'@action':'logout'})" i18n:translate="">Logout</a>
+  </p>
+
+  <p class="classblock"
+       tal:condition="python:request.user.username != 'anonymous'">
+   <b i18n:translate="">Administration</b><br>
+   <a tal:condition="python:request.user.hasPermission('Edit', None)"
+      href="home?@template=classlist" i18n:translate="">Class List</a><br>
+   <a tal:condition="python:request.user.hasPermission('View', 'user')
+                            or request.user.hasPermission('Edit', 'user')"
+      href="user" i18n:translate="">User List</a><br>
+   <a tal:condition="python:request.user.hasPermission('Edit', 'user')"
+      href="user?@template=item" i18n:translate="">Add User</a>
+  </p>
+ </td>
+ <td>
+  <p tal:condition="options/error_message | nothing" class="error-message"
+     tal:repeat="m options/error_message" tal:content="structure m" />
+  <p tal:condition="options/ok_message | nothing" class="ok-message">
+    <span tal:repeat="m options/ok_message"
+       tal:content="structure string:$m <br/ > " />
+     <a class="form-small" tal:attributes="href request/current_url"
+        i18n:translate="">clear this message</a>
+  </p>
+ </td>
+</tr>
+<tr>
+ <td class="content" metal:define-slot="content">Page content goes here</td>
+</tr>
+
+</table>
+
+<pre tal:condition="request/form/debug | nothing" tal:content="request">
+</pre>
+
+</body>
+</html>
+</tal:block>

Added: tracker/vendor/roundup/current/templates/minimal/html/style.css
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/minimal/html/style.css	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,423 @@
+/* main page styles */
+body.body {
+  font-family: sans-serif, Arial, Helvetica;
+  background-color: white;
+  color: #333;
+  margin: 0;
+}
+a[href]:hover {
+  color:blue;
+  text-decoration: underline;
+}
+a[href], a[href]:link {
+  color:blue;
+  text-decoration: none;
+}
+
+table.body {
+  border: 0;
+  padding: 0;
+  border-spacing: 0;
+  border-collapse: separate;
+}
+
+td.page-header-left {
+  padding: 5px;
+  border-bottom: 1px solid #444;
+}
+td.sidebar {
+  padding: 1px 0 0 1px;
+  white-space: nowrap;
+}
+
+/* don't display the sidebar when printing */
+ at media print {
+    td.page-header-left {
+        display: none;
+    }
+    td.sidebar {
+        display: none;
+    }
+    .index-controls {
+        display: none;
+    }
+    #searchbox {
+        display: none;
+    }
+}
+
+td.page-header-top {
+  padding: 5px;
+  border-bottom: 1px solid #444;
+}
+#searchbox {
+    float: right;
+}
+
+div#body-title {
+  float: left;
+}
+
+
+div#searchbox {
+  float: right;
+  padding-top: 1em;
+}
+
+div#searchbox input#search-text {
+  width: 10em;
+}
+
+form {
+  margin: 0;
+}
+
+textarea {
+    font-family: monospace;
+}
+
+td.sidebar p.classblock {
+  padding: 2px 5px 2px 5px;
+  margin: 1px;
+  border: 1px solid #444;
+  background-color: #eee;
+}
+
+td.sidebar p.userblock {
+  padding: 2px 5px 2px 5px;
+  margin: 1px 1px 1px 1px;
+  border: 1px solid #444;
+  background-color: #eef;
+}
+
+.form-small {
+  padding: 0;
+  font-size: 75%;
+}
+
+
+td.content {
+  padding: 1px 5px 1px 5px;
+  vertical-align: top;
+  width: 100%;
+}
+
+td.date, th.date { 
+  white-space: nowrap;
+}
+
+p.ok-message {
+  background-color: #22bb22;
+  padding: 5px;
+  color: white;
+  font-weight: bold;
+}
+p.error-message {
+  background-color: #bb2222;
+  padding: 5px;
+  color: white;
+  font-weight: bold;
+}
+p.error-message a[href] {
+  color: white;
+  text-decoration: underline;
+}
+
+
+/* style for search forms */
+ul.search-checkboxes {
+    display: inline;
+    padding: none;
+    list-style: none;
+}
+ul.search-checkboxes > li {
+    display: inline;
+    padding-right: .5em;
+}
+
+
+/* style for forms */
+table.form {
+  padding: 2px;
+  border-spacing: 0;
+  border-collapse: separate;
+}
+
+table.form th {
+  color: #338;
+  text-align: right;
+  vertical-align: top;
+  font-weight: normal;
+  white-space: nowrap;
+}
+
+table.form th.header {
+  font-weight: bold;
+  background-color: #eef;
+  text-align: left;
+}
+
+table.form th.required {
+  font-weight: bold;
+}
+
+table.form td {
+  color: #333;
+  empty-cells: show;
+  vertical-align: top;
+}
+
+table.form td.optional {
+  font-weight: bold;
+  font-style: italic;
+}
+
+table.form td.html {
+  color: #777;
+}
+
+/* style for lists */
+table.list {
+  border-spacing: 0;
+  border-collapse: separate;
+  width: 100%;
+}
+
+table.list th {
+  padding: 0 4px 0 4px;
+  color: #404070;
+  background-color: #eef;
+  border: 1px solid white;
+  vertical-align: top;
+  empty-cells: show;
+}
+table.list th a[href]:hover { color: #404070 }
+table.list th a[href]:link { color: #404070 }
+table.list th a[href] { color: #404070 }
+table.list th.group {
+  background-color: #f4f4ff;
+  text-align: center;
+}
+
+table.list td {
+  padding: 0 4px 0 4px;
+  border: 1px solid white;
+  color: #404070;
+  background-color: #efefef;
+  vertical-align: top;
+  empty-cells: show;
+}
+
+table.list tr.navigation th {
+  width: 33%;
+  border-style: hidden;
+  text-align: center;
+}
+table.list tr.navigation td {
+    border: none
+}
+table.list tr.navigation th:first-child {
+  text-align: left;
+}
+table.list tr.navigation th:last-child {
+  text-align: right;
+}
+
+
+/* style for message displays */
+table.messages {
+  border-spacing: 0;
+  border-collapse: separate;
+  width: 100%;
+}
+
+table.messages th.header{
+  padding-top: 10px;
+  border-bottom: 1px solid gray;
+  font-weight: bold;
+  background-color: white;
+  color: #707040;
+}
+
+table.messages th {
+  font-weight: bold;
+  color: black;
+  text-align: left;
+  border-bottom: 1px solid #afafaf;
+}
+
+table.messages td {
+  font-family: monospace;
+  background-color: #efefef;
+  border-bottom: 1px solid #afafaf;
+  color: black;
+  empty-cells: show;
+  border-right: 1px solid #afafaf;
+  vertical-align: top;
+  padding: 2px 5px 2px 5px;
+}
+
+table.messages td:first-child {
+  border-left: 1px solid #afafaf;
+  border-right: 1px solid #afafaf;
+}
+
+/* style for file displays */
+table.files {
+  border-spacing: 0;
+  border-collapse: separate;
+  width: 100%;
+}
+
+table.files th.header{
+  padding-top: 10px;
+  border-bottom: 1px solid gray;
+  font-weight: bold;
+  background-color: white;
+  color: #707040;
+}
+
+table.files th {
+  border-bottom: 1px solid #afafaf;
+  font-weight: bold;
+  text-align: left;
+}
+
+table.files td {
+  font-family: monospace;
+  empty-cells: show;
+}
+
+/* style for history displays */
+table.history {
+  border-spacing: 0;
+  border-collapse: separate;
+  width: 100%;
+}
+
+table.history th.header{
+  padding-top: 10px;
+  border-bottom: 1px solid gray;
+  font-weight: bold;
+  background-color: white;
+  color: #707040;
+  font-size: 100%;
+}
+
+table.history th {
+  border-bottom: 1px solid #afafaf;
+  font-weight: bold;
+  text-align: left;
+  font-size: 90%;
+}
+
+table.history td {
+  font-size: 90%;
+  vertical-align: top;
+  empty-cells: show;
+}
+
+
+/* style for class list */
+table.classlist {
+  border-spacing: 0;
+  border-collapse: separate;
+  width: 100%;
+}
+
+table.classlist th.header{
+  padding-top: 10px;
+  border-bottom: 1px solid gray;
+  font-weight: bold;
+  background-color: white;
+  color: #707040;
+}
+
+table.classlist th {
+  font-weight: bold;
+  text-align: left;
+}
+
+
+/* style for class help display */
+table.classhelp {      /* the table-layout: fixed;        */ 
+  table-layout: fixed; /* compromises quality for speed   */
+  overflow: hidden;
+  font-size: .9em;
+  padding-bottom: 3em;
+}
+
+table.classhelp th {
+  font-weight: normal;
+  text-align: left;
+  color: #444;
+  background-color: #efefef;
+  border-bottom: 1px solid #afafaf;
+  border-top: 1px solid #afafaf;
+  text-transform: uppercase;
+  vertical-align: middle;
+  line-height:1.5em;
+}
+
+table.classhelp td {
+  vertical-align: middle;
+  padding-right: .2em;
+  border-bottom: 1px solid #efefef;
+  text-align: left;
+  empty-cells: show;
+  white-space: nowrap;
+  vertical-align: middle;
+}
+
+table.classhelp tr:hover {
+  background-color: #eee;
+}
+
+label.classhelp-label {
+  cursor: pointer;
+}
+
+#classhelp-controls {
+  position: fixed;
+  display: block;
+  top: auto;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  padding: .5em;
+  border-top: 2px solid #444;
+  background-color: #eee;
+}
+
+#classhelp-controls input.apply {
+  width: 7em;
+  font-weight: bold;
+  margin-right: 2em;
+  margin-left: 2em;
+}
+
+#classhelp-controls input.preview {
+   margin-right: 3em;
+   margin-left: 1em;
+}
+
+/* style for "other" displays */
+table.otherinfo {
+  border-spacing: 0;
+  border-collapse: separate;
+  width: 100%;
+}
+
+table.otherinfo th.header{
+  padding-top: 10px;
+  border-bottom: 1px solid gray;
+  font-weight: bold;
+  background-color: white;
+  color: #707040;
+}
+
+table.otherinfo th {
+  border-bottom: 1px solid #afafaf;
+  font-weight: bold;
+  text-align: left;
+}

Added: tracker/vendor/roundup/current/templates/minimal/html/user.index.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/minimal/html/user.index.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,30 @@
+<!-- dollarId: user.index,v 1.3 2002/07/09 05:29:51 richard Exp dollar-->
+<tal:block metal:use-macro="templates/page/macros/icing">
+<title metal:fill-slot="head_title" i18n:translate="">User listing - <span
+ i18n:name="tracker" tal:replace="config/TRACKER_NAME" /></title>
+<span metal:fill-slot="body_title" tal:omit-tag="python:1"
+ i18n:translate="">User listing</span>
+<td class="content" metal:fill-slot="content">
+
+<span tal:condition="not:context/is_view_ok"
+ i18n:translate="">You are not allowed to view this page.</span>
+
+<table width="100%" tal:condition="context/is_view_ok" class="list">
+<tr>
+ <th i18n:translate="">Username</th>
+ <th i18n:translate="">Email address</th>
+</tr>
+<tal:block repeat="user context/list">
+ <tr tal:condition="user/is_view_ok"
+    tal:attributes="class python:['normal', 'alt'][repeat['user'].index%6/3]">
+ <td>
+  <a tal:attributes="href string:user${user/id}"
+     tal:content="user/username">username</a>
+ </td>
+ <td tal:content="python:user.address.email()">address</td>
+ </tr>
+</tal:block>
+</table>
+</td>
+
+</tal:block>

Added: tracker/vendor/roundup/current/templates/minimal/html/user.item.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/minimal/html/user.item.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,102 @@
+<!-- dollarId: user.item,v 1.7 2002/08/16 04:29:04 richard Exp dollar-->
+<tal:block metal:use-macro="templates/page/macros/icing">
+<title metal:fill-slot="head_title">
+<tal:block condition="context/id" i18n:translate=""
+ >User <span tal:replace="context/id" i18n:name="id"
+ />: <span tal:replace="context/username" i18n:name="title"
+ /> - <span tal:replace="config/TRACKER_NAME" i18n:name="tracker"
+/></tal:block>
+<tal:block condition="not:context/id" i18n:translate=""
+ >New User - <span tal:replace="config/TRACKER_NAME" i18n:name="tracker"
+/></tal:block>
+</title>
+<tal:block metal:fill-slot="body_title">
+ <span tal:condition="python: not (context.id or context.is_edit_ok())"
+  tal:omit-tag="python:1" i18n:translate="">New User</span>
+ <span tal:condition="python: not context.id and context.is_edit_ok()"
+  tal:omit-tag="python:1" i18n:translate="">New User Editing</span>
+ <span tal:condition="python: context.id and not context.is_edit_ok()"
+  tal:omit-tag="python:1" i18n:translate="">User<tal:x
+  replace="context/id" i18n:name="id" /></span>
+ <span tal:condition="python: context.id and context.is_edit_ok()"
+  tal:omit-tag="python:1" i18n:translate="">User<tal:x
+  replace="context/id" i18n:name="id" /> Editing</span>
+</tal:block>
+
+<td class="content" metal:fill-slot="content">
+
+<p tal:condition="not:context/is_view_ok" i18n:translate="">
+    You are not allowed to view this page.
+</p>
+
+<div tal:condition="context/is_view_ok">
+
+<form method="POST" onSubmit="return submit_once()"
+      enctype="multipart/form-data"
+      tal:attributes="action context/designator">
+
+<table class="form">
+ <tr>
+  <th class="required" i18n:translate="">Login Name</th>
+  <td tal:content="structure context/username/field">username</td>
+ </tr>
+ <tr tal:condition="context/is_edit_ok">
+  <th i18n:translate="">Login Password</th>
+  <td tal:content="structure context/password/field">password</td>
+ </tr>
+ <tr tal:condition="context/is_edit_ok">
+  <th i18n:translate="">Confirm Password</th>
+  <td tal:content="structure context/password/confirm">password</td>
+ </tr>
+ <tr tal:condition="python:request.user.hasPermission('Web Roles')">
+  <th i18n:translate="">Roles</th>
+  <td>
+   <input tal:condition="context/id"
+          tal:replace="structure context/roles/field">
+   <input name="roles" tal:condition="not:context/id"
+          tal:attributes="value db/config/NEW_WEB_USER_ROLES">
+   <tal:block i18n:translate="">(to give the user more than one role,
+    enter a comma,separated,list)</tal:block>
+  </td>
+ </tr>
+ <tr>
+  <th class="required" i18n:translate="">E-mail address</th>
+  <td tal:define="mailto context/address/field">
+   <a tal:condition="not:context/is_edit_ok"
+    tal:attributes="href string:mailto:${mailto}" tal:content="mailto"
+   /><span tal:condition="context/is_edit_ok" tal:replace="structure mailto" />
+  </td>
+ </tr>
+ <tr>
+  <th i18n:translate="">Alternate E-mail addresses<br>One address per line</th>
+  <td tal:content="structure context/alternate_addresses/multiline">alternate_addresses</td>
+ </tr>
+
+ <tr tal:condition="context/is_edit_ok">
+  <td>
+   &nbsp;
+   <input type="hidden" name="@template" value="item">
+   <input type="hidden" name="@required" value="username,address">
+  </td>
+  <td tal:content="structure context/submit">submit button here</td>
+ </tr>
+</table>
+</form>
+
+<tal:block tal:condition="not:context/id" i18n:translate="">
+<table class="form">
+<tr>
+ <td>Note:&nbsp;</td>
+ <th class="required">highlighted</th>
+ <td>&nbsp;fields are required.</td>
+</tr>
+</table>
+</tal:block>
+
+<tal:block tal:condition="context/id" tal:replace="structure context/history" />
+
+</div>
+
+</td>
+
+</tal:block>

Added: tracker/vendor/roundup/current/templates/minimal/html/user.register.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/minimal/html/user.register.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,70 @@
+<!-- dollarId: user.item,v 1.7 2002/08/16 04:29:04 richard Exp dollar-->
+<tal:block metal:use-macro="templates/page/macros/icing">
+<title metal:fill-slot="head_title"
+ i18n:translate="">Registering with <span i18n:name="tracker"
+ tal:replace="db/config/TRACKER_NAME" /></title>
+<span metal:fill-slot="body_title" tal:omit-tag="python:1"
+ i18n:translate="">Registering with <span i18n:name="tracker"
+ tal:replace="db/config/TRACKER_NAME" /></span>
+<td class="content" metal:fill-slot="content">
+
+<tal:block tal:define=" editok python:request.user.username=='anonymous' and
+           request.user.hasPermission('Web Registration')">
+
+<span tal:condition="python:not editok"
+ i18n:translate="">You are not allowed to view this page.</span>
+
+<tal:block tal:condition="editok">
+<form method="POST" onSubmit="return submit_once()" enctype="multipart/form-data">
+<input type="hidden" name=":template" value="register">
+<input type="hidden" name=":required" value="username">
+<input type="hidden" name=":required" value="password">
+<input type="hidden" name=":required" value="address">
+
+<table class="form">
+ <tr>
+  <th i18n:translate="">Login Name</th>
+  <td tal:content="structure context/username/field">username</td>
+ </tr>
+ <tr>
+  <th i18n:translate="">Login Password</th>
+  <td tal:content="structure context/password/field">password</td>
+ </tr>
+ <tr>
+  <th i18n:translate="">Confirm Password</th>
+  <td tal:content="structure context/password/confirm">password</td>
+ </tr>
+ <tr tal:condition="python:request.user.hasPermission('Web Roles')">
+  <th i18n:translate="">Roles</th>
+  <td tal:condition="exists:item"
+      tal:content="structure context/roles/field">roles</td>
+  <td tal:condition="not:exists:item">
+   <input name="roles" tal:attributes="value db/config/NEW_WEB_USER_ROLES">
+  </td>
+ </tr>
+ <tr>
+  <th i18n:translate="">E-mail address</th>
+  <td tal:content="structure context/address/field">address</td>
+ </tr>
+ <tr>
+  <th i18n:translate="">Alternate E-mail addresses<br>One address per line</th>
+  <td tal:content="structure context/alternate_addresses/multiline">alternate_addresses</td>
+ </tr>
+
+ <tr>
+  <td>&nbsp;</td>
+  <td>
+   <input type="hidden" name=":action" value="register">
+   <input type="submit" name="submit" value="Register" i18n:attributes="value">
+  </td>
+ </tr>
+</table>
+</form>
+
+</tal:block>
+
+</tal:block>
+
+</td>
+
+</tal:block>

Added: tracker/vendor/roundup/current/templates/minimal/html/user.rego_progress.html
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/minimal/html/user.rego_progress.html	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,16 @@
+<!-- dollarId: issue.index,v 1.2 2001/07/29 04:07:37 richard Exp dollar-->
+<tal:block metal:use-macro="templates/page/macros/icing">
+<title metal:fill-slot="head_title"
+ i18n:translate="">Registration in progress - <span i18n:name="tracker"
+ tal:replace="config/TRACKER_NAME" /></title>
+<span metal:fill-slot="body_title" tal:omit-tag="python:1"
+ i18n:translate="">Registration in progress...</span>
+<td class="content" metal:fill-slot="content">
+
+<p i18n:translate="">You will shortly receive an email
+to confirm your registration. To complete the registration process,
+visit the link indicated in the email.
+</p>
+
+</td>
+</tal:block>

Added: tracker/vendor/roundup/current/templates/minimal/initial_data.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/minimal/initial_data.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,14 @@
+#
+# TRACKER DATABASE INITIALIZATION
+#
+
+# create the two default users
+user = db.getclass('user')
+user.create(username="admin", password=adminpw,
+    address=admin_email, roles='Admin')
+user.create(username="anonymous", roles='Anonymous')
+
+# add any additional database creation steps here - but only if you
+# haven't initialised the database with the admin "initialise" command
+
+# vim: set et sts=4 sw=4 :

Added: tracker/vendor/roundup/current/templates/minimal/schema.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/templates/minimal/schema.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,65 @@
+#
+# TRACKER SCHEMA
+#
+
+# Class automatically gets these properties:
+#   creation = Date()
+#   activity = Date()
+#   creator = Link('user')
+#   actor = Link('user')
+
+# The "Minimal" template gets only one class, the required "user"
+# class. That's it. And even that has the bare minimum of properties.
+
+# Note: roles is a comma-separated string of Role names
+user = Class(db, "user", username=String(), password=Password(),
+    address=String(), alternate_addresses=String(), roles=String())
+user.setkey("username")
+#
+# TRACKER SECURITY SETTINGS
+#
+# See the configuration and customisation document for information
+# about security setup.
+
+#
+# REGULAR USERS
+#
+# Give the regular users access to the web and email interface
+db.security.addPermissionToRole('User', 'Web Access')
+db.security.addPermissionToRole('User', 'Email Access')
+
+# May users view other user information?
+# Comment these lines out if you don't want them to
+db.security.addPermissionToRole('User', 'View', 'user')
+
+# Users should be able to edit their own details -- this permission is
+# limited to only the situation where the Viewed or Edited item is their own.
+def own_record(db, userid, itemid):
+    '''Determine whether the userid matches the item being accessed.'''
+    return userid == itemid
+p = db.security.addPermission(name='View', klass='user', check=own_record,
+    description="User is allowed to view their own user details")
+db.security.addPermissionToRole('User', p)
+p = db.security.addPermission(name='Edit', klass='user', check=own_record,
+    description="User is allowed to edit their own user details")
+db.security.addPermissionToRole('User', p)
+
+#
+# ANONYMOUS USER PERMISSIONS
+#
+# Let anonymous users access the web interface. Note that almost all
+# trackers will need this Permission. The only situation where it's not
+# required is in a tracker that uses an HTTP Basic Authenticated front-end.
+db.security.addPermissionToRole('Anonymous', 'Web Access')
+
+# Let anonymous users access the email interface (note that this implies
+# that they will be registered automatically, hence they will need the
+# "Create" user Permission below)
+db.security.addPermissionToRole('Anonymous', 'Email Access')
+
+# Assign the appropriate permissions to the anonymous user's
+# Anonymous Role. Choices here are:
+# - Allow anonymous users to register
+db.security.addPermissionToRole('Anonymous', 'Create', 'user')
+
+# vim: set et sts=4 sw=4 :

Added: tracker/vendor/roundup/current/test/.cvsignore
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/test/.cvsignore	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,5 @@
+*.pyc
+*.pyo
+localconfig.py
+db
+*.cover

Added: tracker/vendor/roundup/current/test/README.txt
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/test/README.txt	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,18 @@
+$Id: README.txt,v 1.3 2004/10/24 08:37:58 a1s Exp $
+
+Structure of the tests:
+
+   1   Test date classes
+   1.1 Date
+   1.2 Interval
+   2   Set up schema
+   3   Open with specific backend
+   3.1 anydbm
+   4   Create database base set (stati, priority, etc)
+   5   Perform some actions
+   6   Perform mail import
+   6.1 text/plain
+   6.2 multipart/mixed (with one text/plain)
+   6.3 text/html
+   6.4 multipart/alternative (with one text/plain)
+   6.5 multipart/alternative (with no text/plain)

Added: tracker/vendor/roundup/current/test/__init__.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/test/__init__.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1 @@
+# make this dir a package

Added: tracker/vendor/roundup/current/test/benchmark.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/test/benchmark.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,134 @@
+import sys, os, time
+
+from roundup.hyperdb import String, Password, Link, Multilink, Date, \
+    Interval, DatabaseError, Boolean, Number
+from roundup import date, password
+
+from db_test_base import config
+
+def setupSchema(db, module):
+    status = module.Class(db, "status", name=String())
+    status.setkey("name")
+    user = module.Class(db, "user", username=String(), password=Password(),
+        assignable=Boolean(), age=Number(), roles=String())
+    user.setkey("username")
+    file = module.FileClass(db, "file", name=String(), type=String(),
+        comment=String(indexme="yes"))
+    issue = module.IssueClass(db, "issue", title=String(indexme="yes"),
+        status=Link("status"), nosy=Multilink("user"), deadline=Date(),
+        foo=Interval(), files=Multilink("file"), assignedto=Link('user'))
+    session = module.Class(db, 'session', title=String())
+    session.disableJournalling()
+    db.post_init()
+    db.commit()
+
+def main(backendname, time=time.time, numissues=10):
+    try:
+        exec('from roundup.backends import %s as backend'%backendname)
+    except ImportError:
+        return
+
+    times = []
+
+    config.DATABASE = os.path.join('_benchmark', '%s-%s'%(backendname,
+        numissues))
+    if not os.path.exists(config.DATABASE):
+        db = backend.Database(config, 'admin')
+        setupSchema(db, backend)
+        # create a whole bunch of stuff
+        db.user.create(**{'username': 'admin'})
+        db.status.create(name="unread")
+        db.status.create(name="in-progress")
+        db.status.create(name="testing")
+        db.status.create(name="resolved")
+        pc = -1
+        for i in range(numissues):
+            db.user.create(**{'username': 'user %s'%i})
+            for j in range(10):
+                db.user.set(str(i+1), assignable=1)
+                db.user.set(str(i+1), assignable=0)
+            db.issue.create(**{'title': 'issue %s'%i})
+            for j in range(10):
+                db.issue.set(str(i+1), status='2', assignedto='2', nosy=[])
+                db.issue.set(str(i+1), status='1', assignedto='1',
+                    nosy=['1','2'])
+            if (i*100/numissues) != pc:
+                pc = (i*100/numissues)
+                sys.stdout.write("%d%%\r"%pc)
+                sys.stdout.flush()
+            db.commit()
+    else:
+        db = backend.Database(config, 'admin')
+        setupSchema(db, backend)
+
+    sys.stdout.write('%7s: %-6d'%(backendname, numissues))
+    sys.stdout.flush()
+
+    times.append(('start', time()))
+
+    # fetch
+    db.clearCache()
+    for i in db.issue.list():
+        db.issue.get(i, 'title')
+    times.append(('fetch', time()))
+
+    # journals
+    db.clearCache()
+    for i in db.issue.list():
+        db.issue.history(i)
+    times.append(('journal', time()))
+
+    # "calculated" props
+    db.clearCache()
+    for i in db.issue.list():
+        db.issue.get(i, 'activity')
+        db.issue.get(i, 'creator')
+        db.issue.get(i, 'creation')
+    times.append(('jprops', time()))
+
+    # lookup
+    db.clearCache()
+    for i in range(numissues):
+        db.user.lookup('user %s'%i)
+    times.append(('lookup', time()))
+
+    # filter
+    db.clearCache()
+    for i in range(100):
+        db.issue.filter(None, {'assignedto': '1', 'title':'issue'},
+            ('+', 'activity'), ('+', 'status'))
+    times.append(('filter', time()))
+
+    # filter with multilink
+    db.clearCache()
+    for i in range(100):
+        db.issue.filter(None, {'nosy': ['1'], 'assignedto': '1',
+            'title':'issue'}, ('+', 'activity'), ('+', 'status'))
+    times.append(('filtml', time()))
+
+    # results
+    last = None
+    for event, stamp in times:
+        if last is None:
+            first = stamp
+        else:
+            sys.stdout.write(' %-6.2f'%(stamp-last))
+        last = stamp
+    print ' %-6.2f'%(last-first)
+    sys.stdout.flush()
+
+if __name__ == '__main__':
+    #      0         1         2         3         4         5         6
+    #      01234567890123456789012345678901234567890123456789012345678901234
+    print 'Test name       fetch  journl jprops lookup filter filtml TOTAL '
+    for name in 'anydbm metakit sqlite'.split():
+        main(name)
+    for name in 'anydbm metakit sqlite'.split():
+        main(name, numissues=20)
+    for name in 'anydbm metakit sqlite'.split():
+        main(name, numissues=100)
+    # don't even bother benchmarking the dbm backends > 100!
+    for name in 'metakit sqlite'.split():
+        main(name, numissues=1000)
+
+# vim: set et sts=4 sw=4 :

Added: tracker/vendor/roundup/current/test/db_test_base.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/test/db_test_base.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,1524 @@
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+#
+# $Id: db_test_base.py,v 1.69 2006/04/27 01:39:47 richard Exp $
+
+import unittest, os, shutil, errno, imp, sys, time, pprint, sets
+
+from roundup.hyperdb import String, Password, Link, Multilink, Date, \
+    Interval, DatabaseError, Boolean, Number, Node
+from roundup import date, password, init, instance, configuration
+
+from mocknull import MockNull
+
+config = configuration.CoreConfig()
+config.DATABASE = "db"
+config.RDBMS_NAME = "rounduptest"
+config.RDBMS_HOST = "localhost"
+config.RDBMS_USER = "rounduptest"
+config.RDBMS_PASSWORD = "rounduptest"
+#config.logging = MockNull()
+# these TRACKER_WEB and MAIL_DOMAIN values are used in mailgw tests
+config.MAIL_DOMAIN = "your.tracker.email.domain.example"
+config.TRACKER_WEB = "http://tracker.example/cgi-bin/roundup.cgi/bugs/"
+# uncomment the following to have excessive debug output from test cases
+# FIXME: tracker logging level should be increased by -v arguments
+#   to 'run_tests.py' script
+#config.LOGGING_FILENAME = "/tmp/logfile"
+#config.LOGGING_LEVEL = "DEBUG"
+config.init_logging()
+
+def setupTracker(dirname, backend="anydbm"):
+    """Install and initialize new tracker in dirname; return tracker instance.
+
+    If the directory exists, it is wiped out before the operation.
+
+    """
+    global config
+    try:
+        shutil.rmtree(dirname)
+    except OSError, error:
+        if error.errno not in (errno.ENOENT, errno.ESRCH): raise
+    # create the instance
+    init.install(dirname, 'templates/classic')
+    init.write_select_db(dirname, backend)
+    config.save(os.path.join(dirname, 'config.ini'))
+    tracker = instance.open(dirname)
+    if tracker.exists():
+        tracker.nuke()
+    tracker.init(password.Password('sekrit'))
+    return tracker
+
+def setupSchema(db, create, module):
+    status = module.Class(db, "status", name=String())
+    status.setkey("name")
+    priority = module.Class(db, "priority", name=String(), order=String())
+    priority.setkey("name")
+    user = module.Class(db, "user", username=String(), password=Password(),
+        assignable=Boolean(), age=Number(), roles=String())
+    user.setkey("username")
+    file = module.FileClass(db, "file", name=String(), type=String(),
+        comment=String(indexme="yes"), fooz=Password())
+    file_nidx = module.FileClass(db, "file_nidx", content=String(indexme='no'))
+    issue = module.IssueClass(db, "issue", title=String(indexme="yes"),
+        status=Link("status"), nosy=Multilink("user"), deadline=Date(),
+        foo=Interval(), files=Multilink("file"), assignedto=Link('user'),
+        priority=Link('priority'))
+    stuff = module.Class(db, "stuff", stuff=String())
+    session = module.Class(db, 'session', title=String())
+    msg = module.FileClass(db, "msg",
+                           author=Link("user", do_journal='no'))
+    session.disableJournalling()
+    db.post_init()
+    if create:
+        user.create(username="admin", roles='Admin',
+            password=password.Password('sekrit'))
+        user.create(username="fred", roles='User',
+            password=password.Password('sekrit'))
+        status.create(name="unread")
+        status.create(name="in-progress")
+        status.create(name="testing")
+        status.create(name="resolved")
+        priority.create(name="feature", order="2")
+        priority.create(name="wish", order="3")
+        priority.create(name="bug", order="1")
+    db.commit()
+
+class MyTestCase(unittest.TestCase):
+    def tearDown(self):
+        if hasattr(self, 'db'):
+            self.db.close()
+        if os.path.exists(config.DATABASE):
+            shutil.rmtree(config.DATABASE)
+
+if os.environ.has_key('LOGGING_LEVEL'):
+    from roundup import rlog
+    config.logging = rlog.BasicLogging()
+    config.logging.setLevel(os.environ['LOGGING_LEVEL'])
+    config.logging.getLogger('hyperdb').setFormat('%(message)s')
+
+class DBTest(MyTestCase):
+    def setUp(self):
+        # remove previous test, ignore errors
+        if os.path.exists(config.DATABASE):
+            shutil.rmtree(config.DATABASE)
+        os.makedirs(config.DATABASE + '/files')
+        self.db = self.module.Database(config, 'admin')
+        setupSchema(self.db, 1, self.module)
+
+    def testRefresh(self):
+        self.db.refresh_database()
+
+    #
+    # automatic properties (well, the two easy ones anyway)
+    #
+    def testCreatorProperty(self):
+        i = self.db.issue
+        id1 = i.create(title='spam')
+        self.db.commit()
+        self.db.close()
+        self.db = self.module.Database(config, 'fred')
+        setupSchema(self.db, 0, self.module)
+        i = self.db.issue
+        id2 = i.create(title='spam')
+        self.assertNotEqual(id1, id2)
+        self.assertNotEqual(i.get(id1, 'creator'), i.get(id2, 'creator'))
+
+    def testActorProperty(self):
+        i = self.db.issue
+        id1 = i.create(title='spam')
+        self.db.commit()
+        self.db.close()
+        self.db = self.module.Database(config, 'fred')
+        setupSchema(self.db, 0, self.module)
+        i = self.db.issue
+        i.set(id1, title='asfasd')
+        self.assertNotEqual(i.get(id1, 'creator'), i.get(id1, 'actor'))
+
+    # ID number controls
+    def testIDGeneration(self):
+        id1 = self.db.issue.create(title="spam", status='1')
+        id2 = self.db.issue.create(title="eggs", status='2')
+        self.assertNotEqual(id1, id2)
+    def testIDSetting(self):
+        # XXX numeric ids
+        self.db.setid('issue', 10)
+        id2 = self.db.issue.create(title="eggs", status='2')
+        self.assertEqual('11', id2)
+
+    #
+    # basic operations
+    #
+    def testEmptySet(self):
+        id1 = self.db.issue.create(title="spam", status='1')
+        self.db.issue.set(id1)
+
+    # String
+    def testStringChange(self):
+        for commit in (0,1):
+            # test set & retrieve
+            nid = self.db.issue.create(title="spam", status='1')
+            self.assertEqual(self.db.issue.get(nid, 'title'), 'spam')
+
+            # change and make sure we retrieve the correct value
+            self.db.issue.set(nid, title='eggs')
+            if commit: self.db.commit()
+            self.assertEqual(self.db.issue.get(nid, 'title'), 'eggs')
+
+    def testStringUnset(self):
+        for commit in (0,1):
+            nid = self.db.issue.create(title="spam", status='1')
+            if commit: self.db.commit()
+            self.assertEqual(self.db.issue.get(nid, 'title'), 'spam')
+            # make sure we can unset
+            self.db.issue.set(nid, title=None)
+            if commit: self.db.commit()
+            self.assertEqual(self.db.issue.get(nid, "title"), None)
+
+    # FileClass "content" property (no unset test)
+    def testFileClassContentChange(self):
+        for commit in (0,1):
+            # test set & retrieve
+            nid = self.db.file.create(content="spam")
+            self.assertEqual(self.db.file.get(nid, 'content'), 'spam')
+
+            # change and make sure we retrieve the correct value
+            self.db.file.set(nid, content='eggs')
+            if commit: self.db.commit()
+            self.assertEqual(self.db.file.get(nid, 'content'), 'eggs')
+
+    # Link
+    def testLinkChange(self):
+        self.assertRaises(IndexError, self.db.issue.create, title="spam",
+            status='100')
+        for commit in (0,1):
+            nid = self.db.issue.create(title="spam", status='1')
+            if commit: self.db.commit()
+            self.assertEqual(self.db.issue.get(nid, "status"), '1')
+            self.db.issue.set(nid, status='2')
+            if commit: self.db.commit()
+            self.assertEqual(self.db.issue.get(nid, "status"), '2')
+
+    def testLinkUnset(self):
+        for commit in (0,1):
+            nid = self.db.issue.create(title="spam", status='1')
+            if commit: self.db.commit()
+            self.db.issue.set(nid, status=None)
+            if commit: self.db.commit()
+            self.assertEqual(self.db.issue.get(nid, "status"), None)
+
+    # Multilink
+    def testMultilinkChange(self):
+        for commit in (0,1):
+            self.assertRaises(IndexError, self.db.issue.create, title="spam",
+                nosy=['foo%s'%commit])
+            u1 = self.db.user.create(username='foo%s'%commit)
+            u2 = self.db.user.create(username='bar%s'%commit)
+            nid = self.db.issue.create(title="spam", nosy=[u1])
+            if commit: self.db.commit()
+            self.assertEqual(self.db.issue.get(nid, "nosy"), [u1])
+            self.db.issue.set(nid, nosy=[])
+            if commit: self.db.commit()
+            self.assertEqual(self.db.issue.get(nid, "nosy"), [])
+            self.db.issue.set(nid, nosy=[u1,u2])
+            if commit: self.db.commit()
+            l = [u1,u2]; l.sort()
+            m = self.db.issue.get(nid, "nosy"); m.sort()
+            self.assertEqual(l, m)
+
+# XXX one day, maybe...
+#    def testMultilinkOrdering(self):
+#        for i in range(10):
+#            self.db.user.create(username='foo%s'%i)
+#        i = self.db.issue.create(title="spam", nosy=['5','3','12','4'])
+#        self.db.commit()
+#        l = self.db.issue.get(i, "nosy")
+#        # all backends should return the Multilink numeric-id-sorted
+#        self.assertEqual(l, ['3', '4', '5', '12'])
+
+    # Date
+    def testDateChange(self):
+        self.assertRaises(TypeError, self.db.issue.create,
+            title='spam', deadline=1)
+        for commit in (0,1):
+            nid = self.db.issue.create(title="spam", status='1')
+            self.assertRaises(TypeError, self.db.issue.set, nid, deadline=1)
+            a = self.db.issue.get(nid, "deadline")
+            if commit: self.db.commit()
+            self.db.issue.set(nid, deadline=date.Date())
+            b = self.db.issue.get(nid, "deadline")
+            if commit: self.db.commit()
+            self.assertNotEqual(a, b)
+            self.assertNotEqual(b, date.Date('1970-1-1.00:00:00'))
+
+    def testDateUnset(self):
+        for commit in (0,1):
+            nid = self.db.issue.create(title="spam", status='1')
+            self.db.issue.set(nid, deadline=date.Date())
+            if commit: self.db.commit()
+            self.assertNotEqual(self.db.issue.get(nid, "deadline"), None)
+            self.db.issue.set(nid, deadline=None)
+            if commit: self.db.commit()
+            self.assertEqual(self.db.issue.get(nid, "deadline"), None)
+
+    # Interval
+    def testIntervalChange(self):
+        self.assertRaises(TypeError, self.db.issue.create,
+            title='spam', foo=1)
+        for commit in (0,1):
+            nid = self.db.issue.create(title="spam", status='1')
+            self.assertRaises(TypeError, self.db.issue.set, nid, foo=1)
+            if commit: self.db.commit()
+            a = self.db.issue.get(nid, "foo")
+            i = date.Interval('-1d')
+            self.db.issue.set(nid, foo=i)
+            if commit: self.db.commit()
+            self.assertNotEqual(self.db.issue.get(nid, "foo"), a)
+            self.assertEqual(i, self.db.issue.get(nid, "foo"))
+            j = date.Interval('1y')
+            self.db.issue.set(nid, foo=j)
+            if commit: self.db.commit()
+            self.assertNotEqual(self.db.issue.get(nid, "foo"), i)
+            self.assertEqual(j, self.db.issue.get(nid, "foo"))
+
+    def testIntervalUnset(self):
+        for commit in (0,1):
+            nid = self.db.issue.create(title="spam", status='1')
+            self.db.issue.set(nid, foo=date.Interval('-1d'))
+            if commit: self.db.commit()
+            self.assertNotEqual(self.db.issue.get(nid, "foo"), None)
+            self.db.issue.set(nid, foo=None)
+            if commit: self.db.commit()
+            self.assertEqual(self.db.issue.get(nid, "foo"), None)
+
+    # Boolean
+    def testBooleanSet(self):
+        nid = self.db.user.create(username='one', assignable=1)
+        self.assertEqual(self.db.user.get(nid, "assignable"), 1)
+        nid = self.db.user.create(username='two', assignable=0)
+        self.assertEqual(self.db.user.get(nid, "assignable"), 0)
+
+    def testBooleanChange(self):
+        userid = self.db.user.create(username='foo', assignable=1)
+        self.assertEqual(1, self.db.user.get(userid, 'assignable'))
+        self.db.user.set(userid, assignable=0)
+        self.assertEqual(self.db.user.get(userid, 'assignable'), 0)
+        self.db.user.set(userid, assignable=1)
+        self.assertEqual(self.db.user.get(userid, 'assignable'), 1)
+
+    def testBooleanUnset(self):
+        nid = self.db.user.create(username='foo', assignable=1)
+        self.db.user.set(nid, assignable=None)
+        self.assertEqual(self.db.user.get(nid, "assignable"), None)
+
+    # Number
+    def testNumberChange(self):
+        nid = self.db.user.create(username='foo', age=1)
+        self.assertEqual(1, self.db.user.get(nid, 'age'))
+        self.db.user.set(nid, age=3)
+        self.assertNotEqual(self.db.user.get(nid, 'age'), 1)
+        self.db.user.set(nid, age=1.0)
+        self.assertEqual(self.db.user.get(nid, 'age'), 1)
+        self.db.user.set(nid, age=0)
+        self.assertEqual(self.db.user.get(nid, 'age'), 0)
+
+        nid = self.db.user.create(username='bar', age=0)
+        self.assertEqual(self.db.user.get(nid, 'age'), 0)
+
+    def testNumberUnset(self):
+        nid = self.db.user.create(username='foo', age=1)
+        self.db.user.set(nid, age=None)
+        self.assertEqual(self.db.user.get(nid, "age"), None)
+
+    # Password
+    def testPasswordChange(self):
+        x = password.Password('x')
+        userid = self.db.user.create(username='foo', password=x)
+        self.assertEqual(x, self.db.user.get(userid, 'password'))
+        self.assertEqual(self.db.user.get(userid, 'password'), 'x')
+        y = password.Password('y')
+        self.db.user.set(userid, password=y)
+        self.assertEqual(self.db.user.get(userid, 'password'), 'y')
+        self.assertRaises(TypeError, self.db.user.create, userid,
+            username='bar', password='x')
+        self.assertRaises(TypeError, self.db.user.set, userid, password='x')
+
+    def testPasswordUnset(self):
+        x = password.Password('x')
+        nid = self.db.user.create(username='foo', password=x)
+        self.db.user.set(nid, assignable=None)
+        self.assertEqual(self.db.user.get(nid, "assignable"), None)
+
+    # key value
+    def testKeyValue(self):
+        self.assertRaises(ValueError, self.db.user.create)
+
+        newid = self.db.user.create(username="spam")
+        self.assertEqual(self.db.user.lookup('spam'), newid)
+        self.db.commit()
+        self.assertEqual(self.db.user.lookup('spam'), newid)
+        self.db.user.retire(newid)
+        self.assertRaises(KeyError, self.db.user.lookup, 'spam')
+
+        # use the key again now that the old is retired
+        newid2 = self.db.user.create(username="spam")
+        self.assertNotEqual(newid, newid2)
+        # try to restore old node. this shouldn't succeed!
+        self.assertRaises(KeyError, self.db.user.restore, newid)
+
+        self.assertRaises(TypeError, self.db.issue.lookup, 'fubar')
+
+    # label property
+    def testLabelProp(self):
+        # key prop
+        self.assertEqual(self.db.status.labelprop(), 'name')
+        self.assertEqual(self.db.user.labelprop(), 'username')
+        # title
+        self.assertEqual(self.db.issue.labelprop(), 'title')
+        # name
+        self.assertEqual(self.db.file.labelprop(), 'name')
+        # id
+        self.assertEqual(self.db.stuff.labelprop(default_to_id=1), 'id')
+
+    # retirement
+    def testRetire(self):
+        self.db.issue.create(title="spam", status='1')
+        b = self.db.status.get('1', 'name')
+        a = self.db.status.list()
+        nodeids = self.db.status.getnodeids()
+        self.db.status.retire('1')
+        others = nodeids[:]
+        others.remove('1')
+
+        self.assertEqual(sets.Set(self.db.status.getnodeids()),
+            sets.Set(nodeids))
+        self.assertEqual(sets.Set(self.db.status.getnodeids(retired=True)),
+            sets.Set(['1']))
+        self.assertEqual(sets.Set(self.db.status.getnodeids(retired=False)),
+            sets.Set(others))
+
+        self.assert_(self.db.status.is_retired('1'))
+
+        # make sure the list is different
+        self.assertNotEqual(a, self.db.status.list())
+
+        # can still access the node if necessary
+        self.assertEqual(self.db.status.get('1', 'name'), b)
+        self.assertRaises(IndexError, self.db.status.set, '1', name='hello')
+        self.db.commit()
+        self.assert_(self.db.status.is_retired('1'))
+        self.assertEqual(self.db.status.get('1', 'name'), b)
+        self.assertNotEqual(a, self.db.status.list())
+
+        # try to restore retired node
+        self.db.status.restore('1')
+
+        self.assert_(not self.db.status.is_retired('1'))
+
+    def testCacheCreateSet(self):
+        self.db.issue.create(title="spam", status='1')
+        a = self.db.issue.get('1', 'title')
+        self.assertEqual(a, 'spam')
+        self.db.issue.set('1', title='ham')
+        b = self.db.issue.get('1', 'title')
+        self.assertEqual(b, 'ham')
+
+    def testSerialisation(self):
+        nid = self.db.issue.create(title="spam", status='1',
+            deadline=date.Date(), foo=date.Interval('-1d'))
+        self.db.commit()
+        assert isinstance(self.db.issue.get(nid, 'deadline'), date.Date)
+        assert isinstance(self.db.issue.get(nid, 'foo'), date.Interval)
+        uid = self.db.user.create(username="fozzy",
+            password=password.Password('t. bear'))
+        self.db.commit()
+        assert isinstance(self.db.user.get(uid, 'password'), password.Password)
+
+    def testTransactions(self):
+        # remember the number of items we started
+        num_issues = len(self.db.issue.list())
+        num_files = self.db.numfiles()
+        self.db.issue.create(title="don't commit me!", status='1')
+        self.assertNotEqual(num_issues, len(self.db.issue.list()))
+        self.db.rollback()
+        self.assertEqual(num_issues, len(self.db.issue.list()))
+        self.db.issue.create(title="please commit me!", status='1')
+        self.assertNotEqual(num_issues, len(self.db.issue.list()))
+        self.db.commit()
+        self.assertNotEqual(num_issues, len(self.db.issue.list()))
+        self.db.rollback()
+        self.assertNotEqual(num_issues, len(self.db.issue.list()))
+        self.db.file.create(name="test", type="text/plain", content="hi")
+        self.db.rollback()
+        self.assertEqual(num_files, self.db.numfiles())
+        for i in range(10):
+            self.db.file.create(name="test", type="text/plain",
+                    content="hi %d"%(i))
+            self.db.commit()
+        num_files2 = self.db.numfiles()
+        self.assertNotEqual(num_files, num_files2)
+        self.db.file.create(name="test", type="text/plain", content="hi")
+        self.db.rollback()
+        self.assertNotEqual(num_files, self.db.numfiles())
+        self.assertEqual(num_files2, self.db.numfiles())
+
+        # rollback / cache interaction
+        name1 = self.db.user.get('1', 'username')
+        self.db.user.set('1', username = name1+name1)
+        # get the prop so the info's forced into the cache (if there is one)
+        self.db.user.get('1', 'username')
+        self.db.rollback()
+        name2 = self.db.user.get('1', 'username')
+        self.assertEqual(name1, name2)
+
+    def testDestroyNoJournalling(self):
+        self.innerTestDestroy(klass=self.db.session)
+
+    def testDestroyJournalling(self):
+        self.innerTestDestroy(klass=self.db.issue)
+
+    def innerTestDestroy(self, klass):
+        newid = klass.create(title='Mr Friendly')
+        n = len(klass.list())
+        self.assertEqual(klass.get(newid, 'title'), 'Mr Friendly')
+        count = klass.count()
+        klass.destroy(newid)
+        self.assertNotEqual(count, klass.count())
+        self.assertRaises(IndexError, klass.get, newid, 'title')
+        self.assertNotEqual(len(klass.list()), n)
+        if klass.do_journal:
+            self.assertRaises(IndexError, klass.history, newid)
+
+        # now with a commit
+        newid = klass.create(title='Mr Friendly')
+        n = len(klass.list())
+        self.assertEqual(klass.get(newid, 'title'), 'Mr Friendly')
+        self.db.commit()
+        count = klass.count()
+        klass.destroy(newid)
+        self.assertNotEqual(count, klass.count())
+        self.assertRaises(IndexError, klass.get, newid, 'title')
+        self.db.commit()
+        self.assertRaises(IndexError, klass.get, newid, 'title')
+        self.assertNotEqual(len(klass.list()), n)
+        if klass.do_journal:
+            self.assertRaises(IndexError, klass.history, newid)
+
+        # now with a rollback
+        newid = klass.create(title='Mr Friendly')
+        n = len(klass.list())
+        self.assertEqual(klass.get(newid, 'title'), 'Mr Friendly')
+        self.db.commit()
+        count = klass.count()
+        klass.destroy(newid)
+        self.assertNotEqual(len(klass.list()), n)
+        self.assertRaises(IndexError, klass.get, newid, 'title')
+        self.db.rollback()
+        self.assertEqual(count, klass.count())
+        self.assertEqual(klass.get(newid, 'title'), 'Mr Friendly')
+        self.assertEqual(len(klass.list()), n)
+        if klass.do_journal:
+            self.assertNotEqual(klass.history(newid), [])
+
+    def testExceptions(self):
+        # this tests the exceptions that should be raised
+        ar = self.assertRaises
+
+        ar(KeyError, self.db.getclass, 'fubar')
+
+        #
+        # class create
+        #
+        # string property
+        ar(TypeError, self.db.status.create, name=1)
+        # id, creation, creator and activity properties are reserved
+        ar(KeyError, self.db.status.create, id=1)
+        ar(KeyError, self.db.status.create, creation=1)
+        ar(KeyError, self.db.status.create, creator=1)
+        ar(KeyError, self.db.status.create, activity=1)
+        ar(KeyError, self.db.status.create, actor=1)
+        # invalid property name
+        ar(KeyError, self.db.status.create, foo='foo')
+        # key name clash
+        ar(ValueError, self.db.status.create, name='unread')
+        # invalid link index
+        ar(IndexError, self.db.issue.create, title='foo', status='bar')
+        # invalid link value
+        ar(ValueError, self.db.issue.create, title='foo', status=1)
+        # invalid multilink type
+        ar(TypeError, self.db.issue.create, title='foo', status='1',
+            nosy='hello')
+        # invalid multilink index type
+        ar(ValueError, self.db.issue.create, title='foo', status='1',
+            nosy=[1])
+        # invalid multilink index
+        ar(IndexError, self.db.issue.create, title='foo', status='1',
+            nosy=['10'])
+
+        #
+        # key property
+        #
+        # key must be a String
+        ar(TypeError, self.db.file.setkey, 'fooz')
+        # key must exist
+        ar(KeyError, self.db.file.setkey, 'fubar')
+
+        #
+        # class get
+        #
+        # invalid node id
+        ar(IndexError, self.db.issue.get, '99', 'title')
+        # invalid property name
+        ar(KeyError, self.db.status.get, '2', 'foo')
+
+        #
+        # class set
+        #
+        # invalid node id
+        ar(IndexError, self.db.issue.set, '99', title='foo')
+        # invalid property name
+        ar(KeyError, self.db.status.set, '1', foo='foo')
+        # string property
+        ar(TypeError, self.db.status.set, '1', name=1)
+        # key name clash
+        ar(ValueError, self.db.status.set, '2', name='unread')
+        # set up a valid issue for me to work on
+        id = self.db.issue.create(title="spam", status='1')
+        # invalid link index
+        ar(IndexError, self.db.issue.set, id, title='foo', status='bar')
+        # invalid link value
+        ar(ValueError, self.db.issue.set, id, title='foo', status=1)
+        # invalid multilink type
+        ar(TypeError, self.db.issue.set, id, title='foo', status='1',
+            nosy='hello')
+        # invalid multilink index type
+        ar(ValueError, self.db.issue.set, id, title='foo', status='1',
+            nosy=[1])
+        # invalid multilink index
+        ar(IndexError, self.db.issue.set, id, title='foo', status='1',
+            nosy=['10'])
+        # NOTE: the following increment the username to avoid problems
+        # within metakit's backend (it creates the node, and then sets the
+        # info, so the create (and by a fluke the username set) go through
+        # before the age/assignable/etc. set, which raises the exception)
+        # invalid number value
+        ar(TypeError, self.db.user.create, username='foo', age='a')
+        # invalid boolean value
+        ar(TypeError, self.db.user.create, username='foo2', assignable='true')
+        nid = self.db.user.create(username='foo3')
+        # invalid number value
+        ar(TypeError, self.db.user.set, nid, age='a')
+        # invalid boolean value
+        ar(TypeError, self.db.user.set, nid, assignable='true')
+
+    def testJournals(self):
+        muid = self.db.user.create(username="mary")
+        self.db.user.create(username="pete")
+        self.db.issue.create(title="spam", status='1')
+        self.db.commit()
+
+        # journal entry for issue create
+        journal = self.db.getjournal('issue', '1')
+        self.assertEqual(1, len(journal))
+        (nodeid, date_stamp, journaltag, action, params) = journal[0]
+        self.assertEqual(nodeid, '1')
+        self.assertEqual(journaltag, self.db.user.lookup('admin'))
+        self.assertEqual(action, 'create')
+        keys = params.keys()
+        keys.sort()
+        self.assertEqual(keys, [])
+
+        # journal entry for link
+        journal = self.db.getjournal('user', '1')
+        self.assertEqual(1, len(journal))
+        self.db.issue.set('1', assignedto='1')
+        self.db.commit()
+        journal = self.db.getjournal('user', '1')
+        self.assertEqual(2, len(journal))
+        (nodeid, date_stamp, journaltag, action, params) = journal[1]
+        self.assertEqual('1', nodeid)
+        self.assertEqual('1', journaltag)
+        self.assertEqual('link', action)
+        self.assertEqual(('issue', '1', 'assignedto'), params)
+
+        # wait a bit to keep proper order of journal entries
+        time.sleep(0.01)
+        # journal entry for unlink
+        self.db.setCurrentUser('mary')
+        self.db.issue.set('1', assignedto='2')
+        self.db.commit()
+        journal = self.db.getjournal('user', '1')
+        self.assertEqual(3, len(journal))
+        (nodeid, date_stamp, journaltag, action, params) = journal[2]
+        self.assertEqual('1', nodeid)
+        self.assertEqual(muid, journaltag)
+        self.assertEqual('unlink', action)
+        self.assertEqual(('issue', '1', 'assignedto'), params)
+
+        # test disabling journalling
+        # ... get the last entry
+        jlen = len(self.db.getjournal('user', '1'))
+        self.db.issue.disableJournalling()
+        self.db.issue.set('1', title='hello world')
+        self.db.commit()
+        # see if the change was journalled when it shouldn't have been
+        self.assertEqual(jlen,  len(self.db.getjournal('user', '1')))
+        jlen = len(self.db.getjournal('issue', '1'))
+        self.db.issue.enableJournalling()
+        self.db.issue.set('1', title='hello world 2')
+        self.db.commit()
+        # see if the change was journalled
+        self.assertNotEqual(jlen,  len(self.db.getjournal('issue', '1')))
+
+    def testJournalPreCommit(self):
+        id = self.db.user.create(username="mary")
+        self.assertEqual(len(self.db.getjournal('user', id)), 1)
+        self.db.commit()
+
+    def testPack(self):
+        id = self.db.issue.create(title="spam", status='1')
+        self.db.commit()
+        time.sleep(1)
+        self.db.issue.set(id, status='2')
+        self.db.commit()
+
+        # sleep for at least a second, then get a date to pack at
+        time.sleep(1)
+        pack_before = date.Date('.')
+
+        # wait another second and add one more entry
+        time.sleep(1)
+        self.db.issue.set(id, status='3')
+        self.db.commit()
+        jlen = len(self.db.getjournal('issue', id))
+
+        # pack
+        self.db.pack(pack_before)
+
+        # we should have the create and last set entries now
+        self.assertEqual(jlen-1, len(self.db.getjournal('issue', id)))
+
+    def testIndexerSearching(self):
+        f1 = self.db.file.create(content='hello', type="text/plain")
+        # content='world' has the wrong content-type and won't be indexed
+        f2 = self.db.file.create(content='world', type="text/frozz",
+            comment='blah blah')
+        i1 = self.db.issue.create(files=[f1, f2], title="flebble plop")
+        i2 = self.db.issue.create(title="flebble the frooz")
+        self.db.commit()
+        self.assertEquals(self.db.indexer.search([], self.db.issue), {})
+        self.assertEquals(self.db.indexer.search(['hello'], self.db.issue),
+            {i1: {'files': [f1]}})
+        self.assertEquals(self.db.indexer.search(['world'], self.db.issue), {})
+        self.assertEquals(self.db.indexer.search(['frooz'], self.db.issue),
+            {i2: {}})
+        self.assertEquals(self.db.indexer.search(['flebble'], self.db.issue),
+            {i1: {}, i2: {}})
+
+        # test AND'ing of search terms
+        self.assertEquals(self.db.indexer.search(['frooz', 'flebble'],
+            self.db.issue), {i2: {}})
+
+        # unindexed stopword
+        self.assertEquals(self.db.indexer.search(['the'], self.db.issue), {})
+
+    def testReindexingChange(self):
+        search = self.db.indexer.search
+        issue = self.db.issue
+        i1 = issue.create(title="flebble plop")
+        i2 = issue.create(title="flebble frooz")
+        self.db.commit()
+        self.assertEquals(search(['plop'], issue), {i1: {}})
+        self.assertEquals(search(['flebble'], issue), {i1: {}, i2: {}})
+
+        # change i1's title
+        issue.set(i1, title="plop")
+        self.db.commit()
+        self.assertEquals(search(['plop'], issue), {i1: {}})
+        self.assertEquals(search(['flebble'], issue), {i2: {}})
+
+    def testReindexingClear(self):
+        search = self.db.indexer.search
+        issue = self.db.issue
+        i1 = issue.create(title="flebble plop")
+        i2 = issue.create(title="flebble frooz")
+        self.db.commit()
+        self.assertEquals(search(['plop'], issue), {i1: {}})
+        self.assertEquals(search(['flebble'], issue), {i1: {}, i2: {}})
+
+        # unset i1's title
+        issue.set(i1, title="")
+        self.db.commit()
+        self.assertEquals(search(['plop'], issue), {})
+        self.assertEquals(search(['flebble'], issue), {i2: {}})
+
+    def testFileClassReindexing(self):
+        f1 = self.db.file.create(content='hello')
+        f2 = self.db.file.create(content='hello, world')
+        i1 = self.db.issue.create(files=[f1, f2])
+        self.db.commit()
+        d = self.db.indexer.search(['hello'], self.db.issue)
+        self.assert_(d.has_key(i1))
+        d[i1]['files'].sort()
+        self.assertEquals(d, {i1: {'files': [f1, f2]}})
+        self.assertEquals(self.db.indexer.search(['world'], self.db.issue),
+            {i1: {'files': [f2]}})
+        self.db.file.set(f1, content="world")
+        self.db.commit()
+        d = self.db.indexer.search(['world'], self.db.issue)
+        d[i1]['files'].sort()
+        self.assertEquals(d, {i1: {'files': [f1, f2]}})
+        self.assertEquals(self.db.indexer.search(['hello'], self.db.issue),
+            {i1: {'files': [f2]}})
+
+    def testFileClassIndexingNoNoNo(self):
+        f1 = self.db.file.create(content='hello')
+        self.db.commit()
+        self.assertEquals(self.db.indexer.search(['hello'], self.db.file),
+            {'1': {}})
+
+        f1 = self.db.file_nidx.create(content='hello')
+        self.db.commit()
+        self.assertEquals(self.db.indexer.search(['hello'], self.db.file_nidx),
+            {})
+
+    def testForcedReindexing(self):
+        self.db.issue.create(title="flebble frooz")
+        self.db.commit()
+        self.assertEquals(self.db.indexer.search(['flebble'], self.db.issue),
+            {'1': {}})
+        self.db.indexer.quiet = 1
+        self.db.indexer.force_reindex()
+        self.db.post_init()
+        self.db.indexer.quiet = 9
+        self.assertEquals(self.db.indexer.search(['flebble'], self.db.issue),
+            {'1': {}})
+
+    #
+    # searching tests follow
+    #
+    def testFindIncorrectProperty(self):
+        self.assertRaises(TypeError, self.db.issue.find, title='fubar')
+
+    def _find_test_setup(self):
+        self.db.file.create(content='')
+        self.db.file.create(content='')
+        self.db.user.create(username='')
+        one = self.db.issue.create(status="1", nosy=['1'])
+        two = self.db.issue.create(status="2", nosy=['2'], files=['1'],
+            assignedto='2')
+        three = self.db.issue.create(status="1", nosy=['1','2'])
+        four = self.db.issue.create(status="3", assignedto='1',
+            files=['1','2'])
+        return one, two, three, four
+
+    def testFindLink(self):
+        one, two, three, four = self._find_test_setup()
+        got = self.db.issue.find(status='1')
+        got.sort()
+        self.assertEqual(got, [one, three])
+        got = self.db.issue.find(status={'1':1})
+        got.sort()
+        self.assertEqual(got, [one, three])
+
+    def testFindLinkFail(self):
+        self._find_test_setup()
+        self.assertEqual(self.db.issue.find(status='4'), [])
+        self.assertEqual(self.db.issue.find(status={'4':1}), [])
+
+    def testFindLinkUnset(self):
+        one, two, three, four = self._find_test_setup()
+        got = self.db.issue.find(assignedto=None)
+        got.sort()
+        self.assertEqual(got, [one, three])
+        got = self.db.issue.find(assignedto={None:1})
+        got.sort()
+        self.assertEqual(got, [one, three])
+
+    def testFindMultipleLink(self):
+        one, two, three, four = self._find_test_setup()
+        l = self.db.issue.find(status={'1':1, '3':1})
+        l.sort()
+        self.assertEqual(l, [one, three, four])
+        l = self.db.issue.find(assignedto={None:1, '1':1})
+        l.sort()
+        self.assertEqual(l, [one, three, four])
+
+    def testFindMultilink(self):
+        one, two, three, four = self._find_test_setup()
+        got = self.db.issue.find(nosy='2')
+        got.sort()
+        self.assertEqual(got, [two, three])
+        got = self.db.issue.find(nosy={'2':1})
+        got.sort()
+        self.assertEqual(got, [two, three])
+        got = self.db.issue.find(nosy={'2':1}, files={})
+        got.sort()
+        self.assertEqual(got, [two, three])
+
+    def testFindMultiMultilink(self):
+        one, two, three, four = self._find_test_setup()
+        got = self.db.issue.find(nosy='2', files='1')
+        got.sort()
+        self.assertEqual(got, [two, three, four])
+        got = self.db.issue.find(nosy={'2':1}, files={'1':1})
+        got.sort()
+        self.assertEqual(got, [two, three, four])
+
+    def testFindMultilinkFail(self):
+        self._find_test_setup()
+        self.assertEqual(self.db.issue.find(nosy='3'), [])
+        self.assertEqual(self.db.issue.find(nosy={'3':1}), [])
+
+    def testFindMultilinkUnset(self):
+        self._find_test_setup()
+        self.assertEqual(self.db.issue.find(nosy={}), [])
+
+    def testFindLinkAndMultilink(self):
+        one, two, three, four = self._find_test_setup()
+        got = self.db.issue.find(status='1', nosy='2')
+        got.sort()
+        self.assertEqual(got, [one, two, three])
+        got = self.db.issue.find(status={'1':1}, nosy={'2':1})
+        got.sort()
+        self.assertEqual(got, [one, two, three])
+
+    def testFindRetired(self):
+        one, two, three, four = self._find_test_setup()
+        self.assertEqual(len(self.db.issue.find(status='1')), 2)
+        self.db.issue.retire(one)
+        self.assertEqual(len(self.db.issue.find(status='1')), 1)
+
+    def testStringFind(self):
+        self.assertRaises(TypeError, self.db.issue.stringFind, status='1')
+
+        ids = []
+        ids.append(self.db.issue.create(title="spam"))
+        self.db.issue.create(title="not spam")
+        ids.append(self.db.issue.create(title="spam"))
+        ids.sort()
+        got = self.db.issue.stringFind(title='spam')
+        got.sort()
+        self.assertEqual(got, ids)
+        self.assertEqual(self.db.issue.stringFind(title='fubar'), [])
+
+        # test retiring a node
+        self.db.issue.retire(ids[0])
+        self.assertEqual(len(self.db.issue.stringFind(title='spam')), 1)
+
+    def filteringSetup(self):
+        for user in (
+                {'username': 'bleep', 'age': 1},
+                {'username': 'blop', 'age': 1.5},
+                {'username': 'blorp', 'age': 2}):
+            self.db.user.create(**user)
+        iss = self.db.issue
+        for issue in (
+                {'title': 'issue one', 'status': '2', 'assignedto': '1',
+                    'foo': date.Interval('1:10'), 'priority': '3',
+                    'deadline': date.Date('2003-02-16.22:50')},
+                {'title': 'issue two', 'status': '1', 'assignedto': '2',
+                    'foo': date.Interval('1d'), 'priority': '3',
+                    'deadline': date.Date('2003-01-01.00:00')},
+                {'title': 'issue three', 'status': '1', 'priority': '2',
+                    'nosy': ['1','2'], 'deadline': date.Date('2003-02-18')},
+                {'title': 'non four', 'status': '3',
+                    'foo': date.Interval('0:10'), 'priority': '2',
+                    'nosy': ['1'], 'deadline': date.Date('2004-03-08')}):
+            self.db.issue.create(**issue)
+        file_content = ''.join([chr(i) for i in range(255)])
+        self.db.file.create(content=file_content)
+        self.db.commit()
+        return self.assertEqual, self.db.issue.filter
+
+    def testFilteringID(self):
+        ae, filt = self.filteringSetup()
+        ae(filt(None, {'id': '1'}, ('+','id'), (None,None)), ['1'])
+        ae(filt(None, {'id': '2'}, ('+','id'), (None,None)), ['2'])
+        ae(filt(None, {'id': '10'}, ('+','id'), (None,None)), [])
+
+    def testFilteringNumber(self):
+        self.filteringSetup()
+        ae, filt = self.assertEqual, self.db.user.filter
+        ae(filt(None, {'age': '1'}, ('+','id'), (None,None)), ['3'])
+        ae(filt(None, {'age': '1.5'}, ('+','id'), (None,None)), ['4'])
+        ae(filt(None, {'age': '2'}, ('+','id'), (None,None)), ['5'])
+        ae(filt(None, {'age': ['1','2']}, ('+','id'), (None,None)), ['3','5'])
+
+    def testFilteringString(self):
+        ae, filt = self.filteringSetup()
+        ae(filt(None, {'title': ['one']}, ('+','id'), (None,None)), ['1'])
+        ae(filt(None, {'title': ['issue one']}, ('+','id'), (None,None)),
+            ['1'])
+        ae(filt(None, {'title': ['issue', 'one']}, ('+','id'), (None,None)),
+            ['1'])
+        ae(filt(None, {'title': ['issue']}, ('+','id'), (None,None)),
+            ['1','2','3'])
+        ae(filt(None, {'title': ['one', 'two']}, ('+','id'), (None,None)),
+            [])
+
+    def testFilteringLink(self):
+        ae, filt = self.filteringSetup()
+        ae(filt(None, {'status': '1'}, ('+','id'), (None,None)), ['2','3'])
+        ae(filt(None, {'assignedto': '-1'}, ('+','id'), (None,None)), ['3','4'])
+        ae(filt(None, {'assignedto': None}, ('+','id'), (None,None)), ['3','4'])
+        ae(filt(None, {'assignedto': ['-1', None]}, ('+','id'), (None,None)),
+            ['3','4'])
+        ae(filt(None, {'assignedto': ['1', None]}, ('+','id'), (None,None)),
+            ['1', '3','4'])
+
+    def testFilteringRetired(self):
+        ae, filt = self.filteringSetup()
+        self.db.issue.retire('2')
+        ae(filt(None, {'status': '1'}, ('+','id'), (None,None)), ['3'])
+
+    def testFilteringMultilink(self):
+        ae, filt = self.filteringSetup()
+        ae(filt(None, {'nosy': '2'}, ('+','id'), (None,None)), ['3'])
+        ae(filt(None, {'nosy': '-1'}, ('+','id'), (None,None)), ['1', '2'])
+        ae(filt(None, {'nosy': ['1','2']}, ('+', 'status'),
+            ('-', 'deadline')), ['4', '3'])
+
+    def testFilteringMany(self):
+        ae, filt = self.filteringSetup()
+        ae(filt(None, {'nosy': '2', 'status': '1'}, ('+','id'), (None,None)),
+            ['3'])
+
+    def testFilteringRange(self):
+        ae, filt = self.filteringSetup()
+        # Date ranges
+        ae(filt(None, {'deadline': 'from 2003-02-10 to 2003-02-23'}), ['1','3'])
+        ae(filt(None, {'deadline': '2003-02-10; 2003-02-23'}), ['1','3'])
+        ae(filt(None, {'deadline': '; 2003-02-16'}), ['2'])
+        # Lets assume people won't invent a time machine, otherwise this test
+        # may fail :)
+        ae(filt(None, {'deadline': 'from 2003-02-16'}), ['1', '3', '4'])
+        ae(filt(None, {'deadline': '2003-02-16;'}), ['1', '3', '4'])
+        ae(filt(None, {'deadline': '2003-02-16;'}), ['1', '3', '4'])
+        # year and month granularity
+        ae(filt(None, {'deadline': '2002'}), [])
+        ae(filt(None, {'deadline': '2003'}), ['1', '2', '3'])
+        ae(filt(None, {'deadline': '2004'}), ['4'])
+        ae(filt(None, {'deadline': '2003-02'}), ['1', '3'])
+        ae(filt(None, {'deadline': '2003-03'}), [])
+        ae(filt(None, {'deadline': '2003-02-16'}), ['1'])
+        ae(filt(None, {'deadline': '2003-02-17'}), [])
+        # Interval ranges
+        ae(filt(None, {'foo': 'from 0:50 to 2:00'}), ['1'])
+        ae(filt(None, {'foo': 'from 0:50 to 1d 2:00'}), ['1', '2'])
+        ae(filt(None, {'foo': 'from 5:50'}), ['2'])
+        ae(filt(None, {'foo': 'to 0:05'}), [])
+
+        # further
+        for issue in (
+                { 'deadline': date.Date('. -2d')},
+                { 'deadline': date.Date('. -1d')},
+                { 'deadline': date.Date('. -8d')},
+                ):
+            self.db.issue.create(**issue)
+        ae(filt(None, {'deadline': '-2d;'}), ['5', '6'])
+        ae(filt(None, {'deadline': '-1d;'}), ['6'])
+        ae(filt(None, {'deadline': '-1w;'}), ['5', '6'])
+
+    def testFilteringIntervalSort(self):
+        # 1: '1:10'
+        # 2: '1d'
+        # 3: None
+        # 4: '0:10'
+        ae, filt = self.filteringSetup()
+        # ascending should sort None, 1:10, 1d
+        ae(filt(None, {}, ('+','foo'), (None,None)), ['3', '4', '1', '2'])
+        # descending should sort 1d, 1:10, None
+        ae(filt(None, {}, ('-','foo'), (None,None)), ['2', '1', '4', '3'])
+
+    def testFilteringMultilinkSort(self):
+        # 1: []
+        # 2: []
+        # 3: ['1','2']
+        # 4: ['1']
+        ae, filt = self.filteringSetup()
+        ae(filt(None, {}, ('+','nosy'), (None,None)), ['1', '2', '4', '3'])
+        ae(filt(None, {}, ('-','nosy'), (None,None)), ['3', '4', '1', '2'])
+
+    def testFilteringLinkSortGroup(self):
+        # 1: status: 2, priority: 3
+        # 2: status: 1, priority: 3
+        # 3: status: 1, priority: 2,
+        # 4: status: 3, priority: 2,
+        ae, filt = self.filteringSetup()
+        ae(filt(None, {}, ('+','status'), ('+','priority')),
+            ['1', '2', '4', '3'])
+
+    def testFilteringDateSort(self):
+        # '1': '2003-02-16.22:50'
+        # '2': '2003-01-01.00:00'
+        # '3': '2003-02-18'
+        # '4': '2004-03-08'
+        ae, filt = self.filteringSetup()
+        # ascending
+        ae(filt(None, {}, ('+','deadline'), (None,None)), ['2', '1', '3', '4'])
+        # descending
+        ae(filt(None, {}, ('-','deadline'), (None,None)), ['4', '3', '1', '2'])
+
+    def testFilteringDateSortPriorityGroup(self):
+        # '1': '2003-02-16.22:50'  1 => 2
+        # '2': '2003-01-01.00:00'  3 => 1
+        # '3': '2003-02-18'        2 => 3
+        # '4': '2004-03-08'        1 => 2
+        ae, filt = self.filteringSetup()
+
+        # ascending
+        ae(filt(None, {}, ('+','deadline'), ('+','priority')),
+            ['2', '1', '3', '4'])
+        ae(filt(None, {}, ('-','deadline'), ('+','priority')),
+            ['1', '2', '4', '3'])
+        # descending
+        ae(filt(None, {}, ('+','deadline'), ('-','priority')),
+            ['3', '4', '2', '1'])
+        ae(filt(None, {}, ('-','deadline'), ('-','priority')),
+            ['4', '3', '1', '2'])
+
+# XXX add sorting tests for other types
+# XXX test auditors and reactors
+
+    def testImportExport(self):
+        # use the filtering setup to create a bunch of items
+        ae, filt = self.filteringSetup()
+        self.db.user.retire('3')
+        self.db.issue.retire('2')
+
+        # grab snapshot of the current database
+        orig = {}
+        for cn,klass in self.db.classes.items():
+            cl = orig[cn] = {}
+            for id in klass.list():
+                it = cl[id] = {}
+                for name in klass.getprops().keys():
+                    it[name] = klass.get(id, name)
+
+        os.mkdir('_test_export')
+        try:
+            # grab the export
+            export = {}
+            journals = {}
+            for cn,klass in self.db.classes.items():
+                names = klass.export_propnames()
+                cl = export[cn] = [names+['is retired']]
+                for id in klass.getnodeids():
+                    cl.append(klass.export_list(names, id))
+                    if hasattr(klass, 'export_files'):
+                        klass.export_files('_test_export', id)
+                journals[cn] = klass.export_journals()
+
+            # shut down this db and nuke it
+            self.db.close()
+            self.nuke_database()
+
+            # open a new, empty database
+            os.makedirs(config.DATABASE + '/files')
+            self.db = self.module.Database(config, 'admin')
+            setupSchema(self.db, 0, self.module)
+
+            # import
+            for cn, items in export.items():
+                klass = self.db.classes[cn]
+                names = items[0]
+                maxid = 1
+                for itemprops in items[1:]:
+                    id = int(klass.import_list(names, itemprops))
+                    if hasattr(klass, 'import_files'):
+                        klass.import_files('_test_export', str(id))
+                    maxid = max(maxid, id)
+                self.db.setid(cn, str(maxid+1))
+                klass.import_journals(journals[cn])
+        finally:
+            shutil.rmtree('_test_export')
+
+        # compare with snapshot of the database
+        for cn, items in orig.items():
+            klass = self.db.classes[cn]
+            propdefs = klass.getprops(1)
+            # ensure retired items are retired :)
+            l = items.keys(); l.sort()
+            m = klass.list(); m.sort()
+            ae(l, m, '%s id list wrong %r vs. %r'%(cn, l, m))
+            for id, props in items.items():
+                for name, value in props.items():
+                    l = klass.get(id, name)
+                    if isinstance(value, type([])):
+                        value.sort()
+                        l.sort()
+                    try:
+                        ae(l, value)
+                    except AssertionError:
+                        if not isinstance(propdefs[name], Date):
+                            raise
+                        # don't get hung up on rounding errors
+                        assert not l.__cmp__(value, int_seconds=1)
+
+        # make sure the retired items are actually imported
+        ae(self.db.user.get('4', 'username'), 'blop')
+        ae(self.db.issue.get('2', 'title'), 'issue two')
+
+        # make sure id counters are set correctly
+        maxid = max([int(id) for id in self.db.user.list()])
+        newid = self.db.user.create(username='testing')
+        assert newid > maxid
+
+    def testAddProperty(self):
+        self.db.issue.create(title="spam", status='1')
+        self.db.commit()
+
+        self.db.issue.addprop(fixer=Link("user"))
+        # force any post-init stuff to happen
+        self.db.post_init()
+        props = self.db.issue.getprops()
+        keys = props.keys()
+        keys.sort()
+        self.assertEqual(keys, ['activity', 'actor', 'assignedto', 'creation',
+            'creator', 'deadline', 'files', 'fixer', 'foo', 'id', 'messages',
+            'nosy', 'priority', 'status', 'superseder', 'title'])
+        self.assertEqual(self.db.issue.get('1', "fixer"), None)
+
+    def testRemoveProperty(self):
+        self.db.issue.create(title="spam", status='1')
+        self.db.commit()
+
+        del self.db.issue.properties['title']
+        self.db.post_init()
+        props = self.db.issue.getprops()
+        keys = props.keys()
+        keys.sort()
+        self.assertEqual(keys, ['activity', 'actor', 'assignedto', 'creation',
+            'creator', 'deadline', 'files', 'foo', 'id', 'messages',
+            'nosy', 'priority', 'status', 'superseder'])
+        self.assertEqual(self.db.issue.list(), ['1'])
+
+    def testAddRemoveProperty(self):
+        self.db.issue.create(title="spam", status='1')
+        self.db.commit()
+
+        self.db.issue.addprop(fixer=Link("user"))
+        del self.db.issue.properties['title']
+        self.db.post_init()
+        props = self.db.issue.getprops()
+        keys = props.keys()
+        keys.sort()
+        self.assertEqual(keys, ['activity', 'actor', 'assignedto', 'creation',
+            'creator', 'deadline', 'files', 'fixer', 'foo', 'id', 'messages',
+            'nosy', 'priority', 'status', 'superseder'])
+        self.assertEqual(self.db.issue.list(), ['1'])
+
+class ROTest(MyTestCase):
+    def setUp(self):
+        # remove previous test, ignore errors
+        if os.path.exists(config.DATABASE):
+            shutil.rmtree(config.DATABASE)
+        os.makedirs(config.DATABASE + '/files')
+        self.db = self.module.Database(config, 'admin')
+        setupSchema(self.db, 1, self.module)
+        self.db.close()
+
+        self.db = self.module.Database(config)
+        setupSchema(self.db, 0, self.module)
+
+    def testExceptions(self):
+        # this tests the exceptions that should be raised
+        ar = self.assertRaises
+
+        # this tests the exceptions that should be raised
+        ar(DatabaseError, self.db.status.create, name="foo")
+        ar(DatabaseError, self.db.status.set, '1', name="foo")
+        ar(DatabaseError, self.db.status.retire, '1')
+
+
+class SchemaTest(MyTestCase):
+    def setUp(self):
+        # remove previous test, ignore errors
+        if os.path.exists(config.DATABASE):
+            shutil.rmtree(config.DATABASE)
+        os.makedirs(config.DATABASE + '/files')
+
+    def test_reservedProperties(self):
+        self.db = self.module.Database(config, 'admin')
+        self.assertRaises(ValueError, self.module.Class, self.db, "a",
+            creation=String())
+        self.assertRaises(ValueError, self.module.Class, self.db, "a",
+            activity=String())
+        self.assertRaises(ValueError, self.module.Class, self.db, "a",
+            creator=String())
+        self.assertRaises(ValueError, self.module.Class, self.db, "a",
+            actor=String())
+
+    def init_a(self):
+        self.db = self.module.Database(config, 'admin')
+        a = self.module.Class(self.db, "a", name=String())
+        a.setkey("name")
+        self.db.post_init()
+
+    def test_fileClassProps(self):
+        self.db = self.module.Database(config, 'admin')
+        a = self.module.FileClass(self.db, 'a')
+        l = a.getprops().keys()
+        l.sort()
+        self.assert_(l, ['activity', 'actor', 'content', 'created',
+            'creation', 'type'])
+
+    def init_ab(self):
+        self.db = self.module.Database(config, 'admin')
+        a = self.module.Class(self.db, "a", name=String())
+        a.setkey("name")
+        b = self.module.Class(self.db, "b", name=String(),
+            fooz=Multilink('a'))
+        b.setkey("name")
+        self.db.post_init()
+
+    def test_addNewClass(self):
+        self.init_a()
+
+        self.assertRaises(ValueError, self.module.Class, self.db, "a",
+            name=String())
+
+        aid = self.db.a.create(name='apple')
+        self.db.commit(); self.db.close()
+
+        # add a new class to the schema and check creation of new items
+        # (and existence of old ones)
+        self.init_ab()
+        bid = self.db.b.create(name='bear', fooz=[aid])
+        self.assertEqual(self.db.a.get(aid, 'name'), 'apple')
+        self.db.commit()
+        self.db.close()
+
+        # now check we can recall the added class' items
+        self.init_ab()
+        self.assertEqual(self.db.a.get(aid, 'name'), 'apple')
+        self.assertEqual(self.db.a.lookup('apple'), aid)
+        self.assertEqual(self.db.b.get(bid, 'name'), 'bear')
+        self.assertEqual(self.db.b.get(bid, 'fooz'), [aid])
+        self.assertEqual(self.db.b.lookup('bear'), bid)
+
+        # confirm journal's ok
+        self.db.getjournal('a', aid)
+        self.db.getjournal('b', bid)
+
+    def init_amod(self):
+        self.db = self.module.Database(config, 'admin')
+        a = self.module.Class(self.db, "a", name=String(), newstr=String(),
+            newint=Interval(), newnum=Number(), newbool=Boolean(),
+            newdate=Date())
+        a.setkey("name")
+        b = self.module.Class(self.db, "b", name=String())
+        b.setkey("name")
+        self.db.post_init()
+
+    def test_modifyClass(self):
+        self.init_ab()
+
+        # add item to user and issue class
+        aid = self.db.a.create(name='apple')
+        bid = self.db.b.create(name='bear')
+        self.db.commit(); self.db.close()
+
+        # modify "a" schema
+        self.init_amod()
+        self.assertEqual(self.db.a.get(aid, 'name'), 'apple')
+        self.assertEqual(self.db.a.get(aid, 'newstr'), None)
+        self.assertEqual(self.db.a.get(aid, 'newint'), None)
+        self.assertEqual(self.db.a.get(aid, 'newnum'), None)
+        self.assertEqual(self.db.a.get(aid, 'newbool'), None)
+        self.assertEqual(self.db.a.get(aid, 'newdate'), None)
+        self.assertEqual(self.db.b.get(aid, 'name'), 'bear')
+        aid2 = self.db.a.create(name='aardvark', newstr='booz')
+        self.db.commit(); self.db.close()
+
+        # test
+        self.init_amod()
+        self.assertEqual(self.db.a.get(aid, 'name'), 'apple')
+        self.assertEqual(self.db.a.get(aid, 'newstr'), None)
+        self.assertEqual(self.db.b.get(aid, 'name'), 'bear')
+        self.assertEqual(self.db.a.get(aid2, 'name'), 'aardvark')
+        self.assertEqual(self.db.a.get(aid2, 'newstr'), 'booz')
+
+        # confirm journal's ok
+        self.db.getjournal('a', aid)
+        self.db.getjournal('a', aid2)
+
+    def init_amodkey(self):
+        self.db = self.module.Database(config, 'admin')
+        a = self.module.Class(self.db, "a", name=String(), newstr=String())
+        a.setkey("newstr")
+        b = self.module.Class(self.db, "b", name=String())
+        b.setkey("name")
+        self.db.post_init()
+
+    def test_changeClassKey(self):
+        self.init_amod()
+        aid = self.db.a.create(name='apple')
+        self.assertEqual(self.db.a.lookup('apple'), aid)
+        self.db.commit(); self.db.close()
+
+        # change the key to newstr on a
+        self.init_amodkey()
+        self.assertEqual(self.db.a.get(aid, 'name'), 'apple')
+        self.assertEqual(self.db.a.get(aid, 'newstr'), None)
+        self.assertRaises(KeyError, self.db.a.lookup, 'apple')
+        aid2 = self.db.a.create(name='aardvark', newstr='booz')
+        self.db.commit(); self.db.close()
+
+        # check
+        self.init_amodkey()
+        self.assertEqual(self.db.a.lookup('booz'), aid2)
+
+        # confirm journal's ok
+        self.db.getjournal('a', aid)
+
+    def test_removeClassKey(self):
+        self.init_amod()
+        aid = self.db.a.create(name='apple')
+        self.assertEqual(self.db.a.lookup('apple'), aid)
+        self.db.commit(); self.db.close()
+
+        self.db = self.module.Database(config, 'admin')
+        a = self.module.Class(self.db, "a", name=String(), newstr=String())
+        self.db.post_init()
+
+        aid2 = self.db.a.create(name='apple', newstr='booz')
+        self.db.commit()
+
+
+    def init_amodml(self):
+        self.db = self.module.Database(config, 'admin')
+        a = self.module.Class(self.db, "a", name=String(),
+            newml=Multilink('a'))
+        a.setkey('name')
+        self.db.post_init()
+
+    def test_makeNewMultilink(self):
+        self.init_a()
+        aid = self.db.a.create(name='apple')
+        self.assertEqual(self.db.a.lookup('apple'), aid)
+        self.db.commit(); self.db.close()
+
+        # add a multilink prop
+        self.init_amodml()
+        bid = self.db.a.create(name='bear', newml=[aid])
+        self.assertEqual(self.db.a.find(newml=aid), [bid])
+        self.assertEqual(self.db.a.lookup('apple'), aid)
+        self.db.commit(); self.db.close()
+
+        # check
+        self.init_amodml()
+        self.assertEqual(self.db.a.find(newml=aid), [bid])
+        self.assertEqual(self.db.a.lookup('apple'), aid)
+        self.assertEqual(self.db.a.lookup('bear'), bid)
+
+        # confirm journal's ok
+        self.db.getjournal('a', aid)
+        self.db.getjournal('a', bid)
+
+    def test_removeMultilink(self):
+        # add a multilink prop
+        self.init_amodml()
+        aid = self.db.a.create(name='apple')
+        bid = self.db.a.create(name='bear', newml=[aid])
+        self.assertEqual(self.db.a.find(newml=aid), [bid])
+        self.assertEqual(self.db.a.lookup('apple'), aid)
+        self.assertEqual(self.db.a.lookup('bear'), bid)
+        self.db.commit(); self.db.close()
+
+        # remove the multilink
+        self.init_a()
+        self.assertEqual(self.db.a.lookup('apple'), aid)
+        self.assertEqual(self.db.a.lookup('bear'), bid)
+
+        # confirm journal's ok
+        self.db.getjournal('a', aid)
+        self.db.getjournal('a', bid)
+
+    def test_removeClass(self):
+        self.init_ab()
+        aid = self.db.a.create(name='apple')
+        bid = self.db.b.create(name='bear')
+        self.db.commit(); self.db.close()
+
+        # drop the b class
+        self.init_a()
+        self.assertEqual(self.db.a.get(aid, 'name'), 'apple')
+        self.assertEqual(self.db.a.lookup('apple'), aid)
+        self.db.commit(); self.db.close()
+
+        # now check we can recall the added class' items
+        self.init_a()
+        self.assertEqual(self.db.a.get(aid, 'name'), 'apple')
+        self.assertEqual(self.db.a.lookup('apple'), aid)
+
+        # confirm journal's ok
+        self.db.getjournal('a', aid)
+
+class RDBMSTest:
+    ''' tests specific to RDBMS backends '''
+    def test_indexTest(self):
+        self.assertEqual(self.db.sql_index_exists('_issue', '_issue_id_idx'), 1)
+        self.assertEqual(self.db.sql_index_exists('_issue', '_issue_x_idx'), 0)
+
+
+class ClassicInitTest(unittest.TestCase):
+    count = 0
+    db = None
+
+    def setUp(self):
+        ClassicInitTest.count = ClassicInitTest.count + 1
+        self.dirname = '_test_init_%s'%self.count
+        try:
+            shutil.rmtree(self.dirname)
+        except OSError, error:
+            if error.errno not in (errno.ENOENT, errno.ESRCH): raise
+
+    def testCreation(self):
+        ae = self.assertEqual
+
+        # set up and open a tracker
+        tracker = setupTracker(self.dirname, self.backend)
+        # open the database
+        db = self.db = tracker.open('test')
+
+        # check the basics of the schema and initial data set
+        l = db.priority.list()
+        ae(l, ['1', '2', '3', '4', '5'])
+        l = db.status.list()
+        ae(l, ['1', '2', '3', '4', '5', '6', '7', '8'])
+        l = db.keyword.list()
+        ae(l, [])
+        l = db.user.list()
+        ae(l, ['1', '2'])
+        l = db.msg.list()
+        ae(l, [])
+        l = db.file.list()
+        ae(l, [])
+        l = db.issue.list()
+        ae(l, [])
+
+    def tearDown(self):
+        if self.db is not None:
+            self.db.close()
+        try:
+            shutil.rmtree(self.dirname)
+        except OSError, error:
+            if error.errno not in (errno.ENOENT, errno.ESRCH): raise
+
+# vim: set et sts=4 sw=4 :

Added: tracker/vendor/roundup/current/test/mocknull.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/test/mocknull.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,23 @@
+
+class MockNull:
+    def __init__(self, **kwargs):
+        for key, value in kwargs.items():
+            self.__dict__[key] = value
+
+    def __call__(self, *args, **kwargs): return MockNull()
+    def __getattr__(self, name):
+        # This allows assignments which assume all intermediate steps are Null
+        # objects if they don't exist yet.
+        #
+        # For example (with just 'client' defined):
+        #
+        # client.db.config.TRACKER_WEB = 'BASE/'
+        self.__dict__[name] = MockNull()
+        return getattr(self, name)
+
+    def __getitem__(self, key): return self
+    def __nonzero__(self): return 0
+    def __str__(self): return ''
+    def __repr__(self): return '<MockNull 0x%x>'%id(self)
+    def gettext(self, str): return str
+    _ = gettext

Added: tracker/vendor/roundup/current/test/session_common.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/test/session_common.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,40 @@
+import os, shutil, unittest
+
+from db_test_base import config
+
+class SessionTest(unittest.TestCase):
+    def setUp(self):
+        # remove previous test, ignore errors
+        if os.path.exists(config.DATABASE):
+            shutil.rmtree(config.DATABASE)
+        os.makedirs(config.DATABASE + '/files')
+        self.db = self.module.Database(config, 'admin')
+        self.sessions = self.sessions_module.Sessions(self.db)
+        self.otks = self.sessions_module.OneTimeKeys(self.db)
+
+    def tearDown(self):
+        del self.otks
+        del self.sessions
+        if hasattr(self, 'db'):
+            self.db.close()
+        if os.path.exists(config.DATABASE):
+            shutil.rmtree(config.DATABASE)
+
+    def testSetSession(self):
+        self.sessions.set('random_key', text='hello, world!')
+        self.assertEqual(self.sessions.get('random_key', 'text'),
+            'hello, world!')
+
+    def testUpdateSession(self):
+        self.sessions.set('random_key', text='hello, world!')
+        self.assertEqual(self.sessions.get('random_key', 'text'),
+            'hello, world!')
+        self.sessions.set('random_key', text='nope')
+        self.assertEqual(self.sessions.get('random_key', 'text'), 'nope')
+
+class DBMTest(SessionTest):
+    import roundup.backends.sessions_dbm as sessions_module
+
+class RDBMSTest(SessionTest):
+    import roundup.backends.sessions_rdbms as sessions_module
+

Added: tracker/vendor/roundup/current/test/test_actions.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/test/test_actions.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,242 @@
+from __future__ import nested_scopes
+
+import unittest
+from cgi import FieldStorage, MiniFieldStorage
+
+from roundup import hyperdb
+from roundup.date import Date, Interval
+from roundup.cgi.actions import *
+from roundup.cgi.exceptions import Redirect, Unauthorised, SeriousError
+
+from mocknull import MockNull
+
+def true(*args, **kwargs):
+    return 1
+
+class ActionTestCase(unittest.TestCase):
+    def setUp(self):
+        self.form = FieldStorage()
+        self.client = MockNull()
+        self.client.form = self.form
+        class TemplatingUtils:
+            pass
+        self.client.instance.interfaces.TemplatingUtils = TemplatingUtils
+
+class ShowActionTestCase(ActionTestCase):
+    def assertRaisesMessage(self, exception, callable, message, *args,
+                            **kwargs):
+        """An extension of assertRaises, which also checks the exception
+        message. We need this because we rely on exception messages when
+        redirecting.
+        """
+        try:
+            callable(*args, **kwargs)
+        except exception, msg:
+            self.assertEqual(str(msg), message)
+        else:
+            if hasattr(exception, '__name__'):
+                excName = exception.__name__
+            else:
+                excName = str(exception)
+            raise self.failureException, excName
+
+    def testShowAction(self):
+        self.client.base = 'BASE/'
+
+        action = ShowAction(self.client)
+        self.assertRaises(ValueError, action.handle)
+
+        self.form.value.append(MiniFieldStorage('@type', 'issue'))
+        self.assertRaises(SeriousError, action.handle)
+
+        self.form.value.append(MiniFieldStorage('@number', '1'))
+        self.assertRaisesMessage(Redirect, action.handle, 'BASE/issue1')
+
+    def testShowActionNoType(self):
+        action = ShowAction(self.client)
+        self.assertRaises(ValueError, action.handle)
+        self.form.value.append(MiniFieldStorage('@number', '1'))
+        self.assertRaisesMessage(ValueError, action.handle,
+            'No type specified')
+
+class RetireActionTestCase(ActionTestCase):
+    def testRetireAction(self):
+        self.client.db.security.hasPermission = true
+        self.client.ok_message = []
+        RetireAction(self.client).handle()
+        self.assert_(len(self.client.ok_message) == 1)
+
+    def testNoPermission(self):
+        self.assertRaises(Unauthorised, RetireAction(self.client).execute)
+
+    def testDontRetireAdminOrAnonymous(self):
+        self.client.db.security.hasPermission=true
+        # look up the user class
+        self.client.classname = 'user'
+        # but always look up admin, regardless of nodeid
+        self.client.db.user.get = lambda a,b: 'admin'
+        self.assertRaises(ValueError, RetireAction(self.client).handle)
+        # .. or anonymous
+        self.client.db.user.get = lambda a,b: 'anonymous'
+        self.assertRaises(ValueError, RetireAction(self.client).handle)
+
+class SearchActionTestCase(ActionTestCase):
+    def setUp(self):
+        ActionTestCase.setUp(self)
+        self.action = SearchAction(self.client)
+
+class StandardSearchActionTestCase(SearchActionTestCase):
+    def testNoPermission(self):
+        self.assertRaises(Unauthorised, self.action.execute)
+
+    def testQueryName(self):
+        self.assertEqual(self.action.getQueryName(), '')
+
+        self.form.value.append(MiniFieldStorage('@queryname', 'foo'))
+        self.assertEqual(self.action.getQueryName(), 'foo')
+
+class FakeFilterVarsTestCase(SearchActionTestCase):
+    def setUp(self):
+        SearchActionTestCase.setUp(self)
+        self.client.db.classes.getprops = lambda: {'foo':
+            hyperdb.Multilink('foo')}
+
+    def assertFilterEquals(self, expected):
+        self.action.fakeFilterVars()
+        self.assertEqual(self.form.getvalue('@filter'), expected)
+
+    def testEmptyMultilink(self):
+        self.form.value.append(MiniFieldStorage('foo', ''))
+        self.form.value.append(MiniFieldStorage('foo', ''))
+
+        self.assertFilterEquals(None)
+
+    def testNonEmptyMultilink(self):
+        self.form.value.append(MiniFieldStorage('foo', ''))
+        self.form.value.append(MiniFieldStorage('foo', '1'))
+
+        self.assertFilterEquals('foo')
+
+    def testEmptyKey(self):
+        self.form.value.append(MiniFieldStorage('foo', ''))
+        self.assertFilterEquals(None)
+
+    def testStandardKey(self):
+        self.form.value.append(MiniFieldStorage('foo', '1'))
+        self.assertFilterEquals('foo')
+
+    def testStringKey(self):
+        self.client.db.classes.getprops = lambda: {'foo': hyperdb.String()}
+        self.form.value.append(MiniFieldStorage('foo', 'hello'))
+        self.assertFilterEquals('foo')
+
+    def testTokenizedStringKey(self):
+        self.client.db.classes.getprops = lambda: {'foo': hyperdb.String()}
+        self.form.value.append(MiniFieldStorage('foo', 'hello world'))
+
+        self.assertFilterEquals('foo')
+
+        # The single value gets replaced with the tokenized list.
+        self.assertEqual([x.value for x in self.form['foo']],
+            ['hello', 'world'])
+
+class CollisionDetectionTestCase(ActionTestCase):
+    def setUp(self):
+        ActionTestCase.setUp(self)
+        self.action = EditItemAction(self.client)
+        self.now = Date('.')
+        # round off for testing
+        self.now.second = int(self.now.second)
+
+    def testLastUserActivity(self):
+        self.assertEqual(self.action.lastUserActivity(), None)
+
+        self.client.form.value.append(
+            MiniFieldStorage('@lastactivity', str(self.now)))
+        self.assertEqual(self.action.lastUserActivity(), self.now)
+
+    def testLastNodeActivity(self):
+        self.action.classname = 'issue'
+        self.action.nodeid = '1'
+
+        def get(nodeid, propname):
+            self.assertEqual(nodeid, '1')
+            self.assertEqual(propname, 'activity')
+            return self.now
+        self.client.db.issue.get = get
+
+        self.assertEqual(self.action.lastNodeActivity(), self.now)
+
+    def testCollision(self):
+        # fake up an actual change
+        self.action.classname = 'test'
+        self.action.nodeid = '1'
+        self.client.parsePropsFromForm = lambda: ({('test','1'):{1:1}}, [])
+        self.failUnless(self.action.detectCollision(self.now,
+            self.now + Interval("1d")))
+        self.failIf(self.action.detectCollision(self.now,
+            self.now - Interval("1d")))
+        self.failIf(self.action.detectCollision(None, self.now))
+
+class LoginTestCase(ActionTestCase):
+    def setUp(self):
+        ActionTestCase.setUp(self)
+        self.client.error_message = []
+
+        # set the db password to 'right'
+        self.client.db.user.get = lambda a,b: 'right'
+
+        # unless explicitly overridden, we should never get here
+        self.client.opendb = lambda a: self.fail(
+            "Logged in, but we shouldn't be.")
+
+    def assertLoginLeavesMessages(self, messages, username=None, password=None):
+        if username is not None:
+            self.form.value.append(MiniFieldStorage('__login_name', username))
+        if password is not None:
+            self.form.value.append(
+                MiniFieldStorage('__login_password', password))
+
+        LoginAction(self.client).handle()
+        self.assertEqual(self.client.error_message, messages)
+
+    def testNoUsername(self):
+        self.assertLoginLeavesMessages(['Username required'])
+
+    def testInvalidUsername(self):
+        def raiseKeyError(a):
+            raise KeyError
+        self.client.db.user.lookup = raiseKeyError
+        self.assertLoginLeavesMessages(['Invalid login'], 'foo')
+
+    def testInvalidPassword(self):
+        self.assertLoginLeavesMessages(['Invalid login'], 'foo', 'wrong')
+
+    def testNoWebAccess(self):
+        self.assertLoginLeavesMessages(['You do not have permission to login'],
+                                        'foo', 'right')
+
+    def testCorrectLogin(self):
+        self.client.db.security.hasPermission = lambda *args, **kwargs: True
+
+        def opendb(username):
+            self.assertEqual(username, 'foo')
+        self.client.opendb = opendb
+
+        self.assertLoginLeavesMessages([], 'foo', 'right')
+
+def test_suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(RetireActionTestCase))
+    suite.addTest(unittest.makeSuite(StandardSearchActionTestCase))
+    suite.addTest(unittest.makeSuite(FakeFilterVarsTestCase))
+    suite.addTest(unittest.makeSuite(ShowActionTestCase))
+    suite.addTest(unittest.makeSuite(CollisionDetectionTestCase))
+    suite.addTest(unittest.makeSuite(LoginTestCase))
+    return suite
+
+if __name__ == '__main__':
+    runner = unittest.TextTestRunner()
+    unittest.main(testRunner=runner)
+
+# vim: set et sts=4 sw=4 :

Added: tracker/vendor/roundup/current/test/test_anydbm.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/test/test_anydbm.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,62 @@
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+# 
+# $Id: test_anydbm.py,v 1.4 2004/11/03 01:34:21 richard Exp $ 
+
+import unittest, os, shutil, time
+from roundup.backends import get_backend
+
+from db_test_base import DBTest, ROTest, SchemaTest, ClassicInitTest, config
+
+class anydbmOpener:
+    module = get_backend('anydbm')
+
+    def nuke_database(self):
+        shutil.rmtree(config.DATABASE)
+
+class anydbmDBTest(anydbmOpener, DBTest):
+    pass
+
+class anydbmROTest(anydbmOpener, ROTest):
+    pass
+
+class anydbmSchemaTest(anydbmOpener, SchemaTest):
+    pass
+
+class anydbmClassicInitTest(ClassicInitTest):
+    backend = 'anydbm'
+
+from session_common import DBMTest
+class anydbmSessionTest(anydbmOpener, DBMTest):
+    pass
+
+def test_suite():
+    suite = unittest.TestSuite()
+    print 'Including anydbm tests'
+    suite.addTest(unittest.makeSuite(anydbmDBTest))
+    suite.addTest(unittest.makeSuite(anydbmROTest))
+    suite.addTest(unittest.makeSuite(anydbmSchemaTest))
+    suite.addTest(unittest.makeSuite(anydbmClassicInitTest))
+    suite.addTest(unittest.makeSuite(anydbmSessionTest))
+    return suite
+
+if __name__ == '__main__':
+    runner = unittest.TextTestRunner()
+    unittest.main(testRunner=runner)
+
+
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/test/test_cgi.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/test/test_cgi.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,551 @@
+#
+# Copyright (c) 2003 Richard Jones, rjones at ekit-inc.com
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# This module 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.
+#
+# $Id: test_cgi.py,v 1.27 2006/01/25 02:24:28 richard Exp $
+
+import unittest, os, shutil, errno, sys, difflib, cgi, re
+
+from roundup.cgi import client
+from roundup.cgi.exceptions import FormError
+from roundup.cgi.form_parser import FormParser
+from roundup import init, instance, password, hyperdb, date
+
+import db_test_base
+
+NEEDS_INSTANCE = 1
+
+class FileUpload:
+    def __init__(self, content, filename):
+        self.content = content
+        self.filename = filename
+
+def makeForm(args):
+    form = cgi.FieldStorage()
+    for k,v in args.items():
+        if type(v) is type([]):
+            [form.list.append(cgi.MiniFieldStorage(k, x)) for x in v]
+        elif isinstance(v, FileUpload):
+            x = cgi.MiniFieldStorage(k, v.content)
+            x.filename = v.filename
+            form.list.append(x)
+        else:
+            form.list.append(cgi.MiniFieldStorage(k, v))
+    return form
+
+cm = client.clean_message
+class MessageTestCase(unittest.TestCase):
+    def testCleanMessageOK(self):
+        self.assertEqual(cm('<br>x<br />'), '<br>x<br />')
+        self.assertEqual(cm('<i>x</i>'), '<i>x</i>')
+        self.assertEqual(cm('<b>x</b>'), '<b>x</b>')
+        self.assertEqual(cm('<a href="y">x</a>'),
+            '<a href="y">x</a>')
+        self.assertEqual(cm('<BR>x<BR />'), '<BR>x<BR />')
+        self.assertEqual(cm('<I>x</I>'), '<I>x</I>')
+        self.assertEqual(cm('<B>x</B>'), '<B>x</B>')
+        self.assertEqual(cm('<A HREF="y">x</A>'),
+            '<A HREF="y">x</A>')
+
+    def testCleanMessageBAD(self):
+        self.assertEqual(cm('<script>x</script>'),
+            '&lt;script&gt;x&lt;/script&gt;')
+        self.assertEqual(cm('<iframe>x</iframe>'),
+            '&lt;iframe&gt;x&lt;/iframe&gt;')
+
+class FormTestCase(unittest.TestCase):
+    def setUp(self):
+        self.dirname = '_test_cgi_form'
+        # set up and open a tracker
+        self.instance = db_test_base.setupTracker(self.dirname)
+
+        # open the database
+        self.db = self.instance.open('admin')
+        self.db.user.create(username='Chef', address='chef at bork.bork.bork',
+            realname='Bork, Chef', roles='User')
+        self.db.user.create(username='mary', address='mary at test',
+            roles='User', realname='Contrary, Mary')
+
+        test = self.instance.backend.Class(self.db, "test",
+            string=hyperdb.String(), number=hyperdb.Number(),
+            boolean=hyperdb.Boolean(), link=hyperdb.Link('test'),
+            multilink=hyperdb.Multilink('test'), date=hyperdb.Date(),
+            interval=hyperdb.Interval())
+
+        # compile the labels re
+        classes = '|'.join(self.db.classes.keys())
+        self.FV_SPECIAL = re.compile(FormParser.FV_LABELS%classes,
+            re.VERBOSE)
+
+    def parseForm(self, form, classname='test', nodeid=None):
+        cl = client.Client(self.instance, None, {'PATH_INFO':'/'},
+            makeForm(form))
+        cl.classname = classname
+        cl.nodeid = nodeid
+        cl.db = self.db
+        return cl.parsePropsFromForm(create=1)
+
+    def tearDown(self):
+        self.db.close()
+        try:
+            shutil.rmtree(self.dirname)
+        except OSError, error:
+            if error.errno not in (errno.ENOENT, errno.ESRCH): raise
+
+    #
+    # form label extraction
+    #
+    def tl(self, s, c, i, a, p):
+        m = self.FV_SPECIAL.match(s)
+        self.assertNotEqual(m, None)
+        d = m.groupdict()
+        self.assertEqual(d['classname'], c)
+        self.assertEqual(d['id'], i)
+        for action in 'required add remove link note file'.split():
+            if a == action:
+                self.assertNotEqual(d[action], None)
+            else:
+                self.assertEqual(d[action], None)
+        self.assertEqual(d['propname'], p)
+
+    def testLabelMatching(self):
+        self.tl('<propname>', None, None, None, '<propname>')
+        self.tl(':required', None, None, 'required', None)
+        self.tl(':confirm:<propname>', None, None, 'confirm', '<propname>')
+        self.tl(':add:<propname>', None, None, 'add', '<propname>')
+        self.tl(':remove:<propname>', None, None, 'remove', '<propname>')
+        self.tl(':link:<propname>', None, None, 'link', '<propname>')
+        self.tl('test1:<prop>', 'test', '1', None, '<prop>')
+        self.tl('test1:required', 'test', '1', 'required', None)
+        self.tl('test1:add:<prop>', 'test', '1', 'add', '<prop>')
+        self.tl('test1:remove:<prop>', 'test', '1', 'remove', '<prop>')
+        self.tl('test1:link:<prop>', 'test', '1', 'link', '<prop>')
+        self.tl('test1:confirm:<prop>', 'test', '1', 'confirm', '<prop>')
+        self.tl('test-1:<prop>', 'test', '-1', None, '<prop>')
+        self.tl('test-1:required', 'test', '-1', 'required', None)
+        self.tl('test-1:add:<prop>', 'test', '-1', 'add', '<prop>')
+        self.tl('test-1:remove:<prop>', 'test', '-1', 'remove', '<prop>')
+        self.tl('test-1:link:<prop>', 'test', '-1', 'link', '<prop>')
+        self.tl('test-1:confirm:<prop>', 'test', '-1', 'confirm', '<prop>')
+        self.tl(':note', None, None, 'note', None)
+        self.tl(':file', None, None, 'file', None)
+
+    #
+    # Empty form
+    #
+    def testNothing(self):
+        self.assertEqual(self.parseForm({}), ({('test', None): {}}, []))
+
+    def testNothingWithRequired(self):
+        self.assertRaises(FormError, self.parseForm, {':required': 'string'})
+        self.assertRaises(FormError, self.parseForm,
+            {':required': 'title,status', 'status':'1'}, 'issue')
+        self.assertRaises(FormError, self.parseForm,
+            {':required': ['title','status'], 'status':'1'}, 'issue')
+        self.assertRaises(FormError, self.parseForm,
+            {':required': 'status', 'status':''}, 'issue')
+        self.assertRaises(FormError, self.parseForm,
+            {':required': 'nosy', 'nosy':''}, 'issue')
+
+    #
+    # Nonexistant edit
+    #
+    def testEditNonexistant(self):
+        self.assertRaises(FormError, self.parseForm, {'boolean': ''},
+            'test', '1')
+
+    #
+    # String
+    #
+    def testEmptyString(self):
+        self.assertEqual(self.parseForm({'string': ''}),
+            ({('test', None): {}}, []))
+        self.assertEqual(self.parseForm({'string': ' '}),
+            ({('test', None): {}}, []))
+        self.assertRaises(FormError, self.parseForm, {'string': ['', '']})
+
+    def testSetString(self):
+        self.assertEqual(self.parseForm({'string': 'foo'}),
+            ({('test', None): {'string': 'foo'}}, []))
+        self.assertEqual(self.parseForm({'string': 'a\r\nb\r\n'}),
+            ({('test', None): {'string': 'a\nb'}}, []))
+        nodeid = self.db.issue.create(title='foo')
+        self.assertEqual(self.parseForm({'title': 'foo'}, 'issue', nodeid),
+            ({('issue', nodeid): {}}, []))
+
+    def testEmptyStringSet(self):
+        nodeid = self.db.issue.create(title='foo')
+        self.assertEqual(self.parseForm({'title': ''}, 'issue', nodeid),
+            ({('issue', nodeid): {'title': None}}, []))
+        nodeid = self.db.issue.create(title='foo')
+        self.assertEqual(self.parseForm({'title': ' '}, 'issue', nodeid),
+            ({('issue', nodeid): {'title': None}}, []))
+
+    def testFileUpload(self):
+        file = FileUpload('foo', 'foo.txt')
+        self.assertEqual(self.parseForm({'content': file}, 'file'),
+            ({('file', None): {'content': 'foo', 'name': 'foo.txt',
+            'type': 'text/plain'}}, []))
+
+    def testEditFileClassAttributes(self):
+        self.assertEqual(self.parseForm({'name': 'foo.txt',
+                                         'type': 'application/octet-stream'},
+                                        'file'),
+                         ({('file', None): {'name': 'foo.txt',
+                                            'type': 'application/octet-stream'}},[]))
+
+    #
+    # Link
+    #
+    def testEmptyLink(self):
+        self.assertEqual(self.parseForm({'link': ''}),
+            ({('test', None): {}}, []))
+        self.assertEqual(self.parseForm({'link': ' '}),
+            ({('test', None): {}}, []))
+        self.assertRaises(FormError, self.parseForm, {'link': ['', '']})
+        self.assertEqual(self.parseForm({'link': '-1'}),
+            ({('test', None): {}}, []))
+
+    def testSetLink(self):
+        self.assertEqual(self.parseForm({'status': 'unread'}, 'issue'),
+            ({('issue', None): {'status': '1'}}, []))
+        self.assertEqual(self.parseForm({'status': '1'}, 'issue'),
+            ({('issue', None): {'status': '1'}}, []))
+        nodeid = self.db.issue.create(status='unread')
+        self.assertEqual(self.parseForm({'status': 'unread'}, 'issue', nodeid),
+            ({('issue', nodeid): {}}, []))
+
+    def testUnsetLink(self):
+        nodeid = self.db.issue.create(status='unread')
+        self.assertEqual(self.parseForm({'status': '-1'}, 'issue', nodeid),
+            ({('issue', nodeid): {'status': None}}, []))
+
+    def testInvalidLinkValue(self):
+# XXX This is not the current behaviour - should we enforce this?
+#        self.assertRaises(IndexError, self.parseForm,
+#            {'status': '4'}))
+        self.assertRaises(FormError, self.parseForm, {'link': 'frozzle'})
+        self.assertRaises(FormError, self.parseForm, {'status': 'frozzle'},
+            'issue')
+
+    #
+    # Multilink
+    #
+    def testEmptyMultilink(self):
+        self.assertEqual(self.parseForm({'nosy': ''}),
+            ({('test', None): {}}, []))
+        self.assertEqual(self.parseForm({'nosy': ' '}),
+            ({('test', None): {}}, []))
+
+    def testSetMultilink(self):
+        self.assertEqual(self.parseForm({'nosy': '1'}, 'issue'),
+            ({('issue', None): {'nosy': ['1']}}, []))
+        self.assertEqual(self.parseForm({'nosy': 'admin'}, 'issue'),
+            ({('issue', None): {'nosy': ['1']}}, []))
+        self.assertEqual(self.parseForm({'nosy': ['1','2']}, 'issue'),
+            ({('issue', None): {'nosy': ['1','2']}}, []))
+        self.assertEqual(self.parseForm({'nosy': '1,2'}, 'issue'),
+            ({('issue', None): {'nosy': ['1','2']}}, []))
+        self.assertEqual(self.parseForm({'nosy': 'admin,2'}, 'issue'),
+            ({('issue', None): {'nosy': ['1','2']}}, []))
+
+    def testMixedMultilink(self):
+        form = cgi.FieldStorage()
+        form.list.append(cgi.MiniFieldStorage('nosy', '1,2'))
+        form.list.append(cgi.MiniFieldStorage('nosy', '3'))
+        cl = client.Client(self.instance, None, {'PATH_INFO':'/'}, form)
+        cl.classname = 'issue'
+        cl.nodeid = None
+        cl.db = self.db
+        self.assertEqual(cl.parsePropsFromForm(create=1),
+            ({('issue', None): {'nosy': ['1','2', '3']}}, []))
+
+    def testEmptyMultilinkSet(self):
+        nodeid = self.db.issue.create(nosy=['1','2'])
+        self.assertEqual(self.parseForm({'nosy': ''}, 'issue', nodeid),
+            ({('issue', nodeid): {'nosy': []}}, []))
+        nodeid = self.db.issue.create(nosy=['1','2'])
+        self.assertEqual(self.parseForm({'nosy': ' '}, 'issue', nodeid),
+            ({('issue', nodeid): {'nosy': []}}, []))
+        self.assertEqual(self.parseForm({'nosy': '1,2'}, 'issue', nodeid),
+            ({('issue', nodeid): {}}, []))
+
+    def testInvalidMultilinkValue(self):
+# XXX This is not the current behaviour - should we enforce this?
+#        self.assertRaises(IndexError, self.parseForm,
+#            {'nosy': '4'}))
+        self.assertRaises(FormError, self.parseForm, {'nosy': 'frozzle'},
+            'issue')
+        self.assertRaises(FormError, self.parseForm, {'nosy': '1,frozzle'},
+            'issue')
+        self.assertRaises(FormError, self.parseForm, {'multilink': 'frozzle'})
+
+    def testMultilinkAdd(self):
+        nodeid = self.db.issue.create(nosy=['1'])
+        # do nothing
+        self.assertEqual(self.parseForm({':add:nosy': ''}, 'issue', nodeid),
+            ({('issue', nodeid): {}}, []))
+
+        # do something ;)
+        self.assertEqual(self.parseForm({':add:nosy': '2'}, 'issue', nodeid),
+            ({('issue', nodeid): {'nosy': ['1','2']}}, []))
+        self.assertEqual(self.parseForm({':add:nosy': '2,mary'}, 'issue',
+            nodeid), ({('issue', nodeid): {'nosy': ['1','2','4']}}, []))
+        self.assertEqual(self.parseForm({':add:nosy': ['2','3']}, 'issue',
+            nodeid), ({('issue', nodeid): {'nosy': ['1','2','3']}}, []))
+
+    def testMultilinkAddNew(self):
+        self.assertEqual(self.parseForm({':add:nosy': ['2','3']}, 'issue'),
+            ({('issue', None): {'nosy': ['2','3']}}, []))
+
+    def testMultilinkRemove(self):
+        nodeid = self.db.issue.create(nosy=['1','2'])
+        # do nothing
+        self.assertEqual(self.parseForm({':remove:nosy': ''}, 'issue', nodeid),
+            ({('issue', nodeid): {}}, []))
+
+        # do something ;)
+        self.assertEqual(self.parseForm({':remove:nosy': '1'}, 'issue',
+            nodeid), ({('issue', nodeid): {'nosy': ['2']}}, []))
+        self.assertEqual(self.parseForm({':remove:nosy': 'admin,2'},
+            'issue', nodeid), ({('issue', nodeid): {'nosy': []}}, []))
+        self.assertEqual(self.parseForm({':remove:nosy': ['1','2']},
+            'issue', nodeid), ({('issue', nodeid): {'nosy': []}}, []))
+
+        # add and remove
+        self.assertEqual(self.parseForm({':add:nosy': ['3'],
+            ':remove:nosy': ['1','2']},
+            'issue', nodeid), ({('issue', nodeid): {'nosy': ['3']}}, []))
+
+        # remove one that doesn't exist?
+        self.assertRaises(FormError, self.parseForm, {':remove:nosy': '4'},
+            'issue', nodeid)
+
+    def testMultilinkRetired(self):
+        self.db.user.retire('2')
+        self.assertEqual(self.parseForm({'nosy': ['2','3']}, 'issue'),
+            ({('issue', None): {'nosy': ['2','3']}}, []))
+        nodeid = self.db.issue.create(nosy=['1','2'])
+        self.assertEqual(self.parseForm({':remove:nosy': '2'}, 'issue',
+            nodeid), ({('issue', nodeid): {'nosy': ['1']}}, []))
+        self.assertEqual(self.parseForm({':add:nosy': '3'}, 'issue', nodeid),
+            ({('issue', nodeid): {'nosy': ['1','2','3']}}, []))
+
+    def testAddRemoveNonexistant(self):
+        self.assertRaises(FormError, self.parseForm, {':remove:foo': '2'},
+            'issue')
+        self.assertRaises(FormError, self.parseForm, {':add:foo': '2'},
+            'issue')
+
+    #
+    # Password
+    #
+    def testEmptyPassword(self):
+        self.assertEqual(self.parseForm({'password': ''}, 'user'),
+            ({('user', None): {}}, []))
+        self.assertEqual(self.parseForm({'password': ''}, 'user'),
+            ({('user', None): {}}, []))
+        self.assertRaises(FormError, self.parseForm, {'password': ['', '']},
+            'user')
+        self.assertRaises(FormError, self.parseForm, {'password': 'foo',
+            ':confirm:password': ['', '']}, 'user')
+
+    def testSetPassword(self):
+        self.assertEqual(self.parseForm({'password': 'foo',
+            ':confirm:password': 'foo'}, 'user'),
+            ({('user', None): {'password': 'foo'}}, []))
+
+    def testSetPasswordConfirmBad(self):
+        self.assertRaises(FormError, self.parseForm, {'password': 'foo'},
+            'user')
+        self.assertRaises(FormError, self.parseForm, {'password': 'foo',
+            ':confirm:password': 'bar'}, 'user')
+
+    def testEmptyPasswordNotSet(self):
+        nodeid = self.db.user.create(username='1',
+            password=password.Password('foo'))
+        self.assertEqual(self.parseForm({'password': ''}, 'user', nodeid),
+            ({('user', nodeid): {}}, []))
+        nodeid = self.db.user.create(username='2',
+            password=password.Password('foo'))
+        self.assertEqual(self.parseForm({'password': '',
+            ':confirm:password': ''}, 'user', nodeid),
+            ({('user', nodeid): {}}, []))
+
+    #
+    # Boolean
+    #
+    def testEmptyBoolean(self):
+        self.assertEqual(self.parseForm({'boolean': ''}),
+            ({('test', None): {}}, []))
+        self.assertEqual(self.parseForm({'boolean': ' '}),
+            ({('test', None): {}}, []))
+        self.assertRaises(FormError, self.parseForm, {'boolean': ['', '']})
+
+    def testSetBoolean(self):
+        self.assertEqual(self.parseForm({'boolean': 'yes'}),
+            ({('test', None): {'boolean': 1}}, []))
+        self.assertEqual(self.parseForm({'boolean': 'a\r\nb\r\n'}),
+            ({('test', None): {'boolean': 0}}, []))
+        nodeid = self.db.test.create(boolean=1)
+        self.assertEqual(self.parseForm({'boolean': 'yes'}, 'test', nodeid),
+            ({('test', nodeid): {}}, []))
+        nodeid = self.db.test.create(boolean=0)
+        self.assertEqual(self.parseForm({'boolean': 'no'}, 'test', nodeid),
+            ({('test', nodeid): {}}, []))
+
+    def testEmptyBooleanSet(self):
+        nodeid = self.db.test.create(boolean=0)
+        self.assertEqual(self.parseForm({'boolean': ''}, 'test', nodeid),
+            ({('test', nodeid): {'boolean': None}}, []))
+        nodeid = self.db.test.create(boolean=1)
+        self.assertEqual(self.parseForm({'boolean': ' '}, 'test', nodeid),
+            ({('test', nodeid): {'boolean': None}}, []))
+
+    #
+    # Number
+    #
+    def testEmptyNumber(self):
+        self.assertEqual(self.parseForm({'number': ''}),
+            ({('test', None): {}}, []))
+        self.assertEqual(self.parseForm({'number': ' '}),
+            ({('test', None): {}}, []))
+        self.assertRaises(FormError, self.parseForm, {'number': ['', '']})
+
+    def testInvalidNumber(self):
+        self.assertRaises(FormError, self.parseForm, {'number': 'hi, mum!'})
+
+    def testSetNumber(self):
+        self.assertEqual(self.parseForm({'number': '1'}),
+            ({('test', None): {'number': 1}}, []))
+        self.assertEqual(self.parseForm({'number': '0'}),
+            ({('test', None): {'number': 0}}, []))
+        self.assertEqual(self.parseForm({'number': '\n0\n'}),
+            ({('test', None): {'number': 0}}, []))
+
+    def testSetNumberReplaceOne(self):
+        nodeid = self.db.test.create(number=1)
+        self.assertEqual(self.parseForm({'number': '1'}, 'test', nodeid),
+            ({('test', nodeid): {}}, []))
+        self.assertEqual(self.parseForm({'number': '0'}, 'test', nodeid),
+            ({('test', nodeid): {'number': 0}}, []))
+
+    def testSetNumberReplaceZero(self):
+        nodeid = self.db.test.create(number=0)
+        self.assertEqual(self.parseForm({'number': '0'}, 'test', nodeid),
+            ({('test', nodeid): {}}, []))
+
+    def testSetNumberReplaceNone(self):
+        nodeid = self.db.test.create()
+        self.assertEqual(self.parseForm({'number': '0'}, 'test', nodeid),
+            ({('test', nodeid): {'number': 0}}, []))
+        self.assertEqual(self.parseForm({'number': '1'}, 'test', nodeid),
+            ({('test', nodeid): {'number': 1}}, []))
+
+    def testEmptyNumberSet(self):
+        nodeid = self.db.test.create(number=0)
+        self.assertEqual(self.parseForm({'number': ''}, 'test', nodeid),
+            ({('test', nodeid): {'number': None}}, []))
+        nodeid = self.db.test.create(number=1)
+        self.assertEqual(self.parseForm({'number': ' '}, 'test', nodeid),
+            ({('test', nodeid): {'number': None}}, []))
+
+    #
+    # Date
+    #
+    def testEmptyDate(self):
+        self.assertEqual(self.parseForm({'date': ''}),
+            ({('test', None): {}}, []))
+        self.assertEqual(self.parseForm({'date': ' '}),
+            ({('test', None): {}}, []))
+        self.assertRaises(FormError, self.parseForm, {'date': ['', '']})
+
+    def testInvalidDate(self):
+        self.assertRaises(FormError, self.parseForm, {'date': '12'})
+
+    def testSetDate(self):
+        self.assertEqual(self.parseForm({'date': '2003-01-01'}),
+            ({('test', None): {'date': date.Date('2003-01-01')}}, []))
+        nodeid = self.db.test.create(date=date.Date('2003-01-01'))
+        self.assertEqual(self.parseForm({'date': '2003-01-01'}, 'test',
+            nodeid), ({('test', nodeid): {}}, []))
+
+    def testEmptyDateSet(self):
+        nodeid = self.db.test.create(date=date.Date('.'))
+        self.assertEqual(self.parseForm({'date': ''}, 'test', nodeid),
+            ({('test', nodeid): {'date': None}}, []))
+        nodeid = self.db.test.create(date=date.Date('1970-01-01.00:00:00'))
+        self.assertEqual(self.parseForm({'date': ' '}, 'test', nodeid),
+            ({('test', nodeid): {'date': None}}, []))
+
+    #
+    # Test multiple items in form
+    #
+    def testMultiple(self):
+        self.assertEqual(self.parseForm({'string': 'a', 'issue-1 at title': 'b'}),
+            ({('test', None): {'string': 'a'},
+              ('issue', '-1'): {'title': 'b'}
+             }, []))
+
+    def testMultipleExistingContext(self):
+        nodeid = self.db.test.create()
+        self.assertEqual(self.parseForm({'string': 'a', 'issue-1 at title': 'b'},
+            'test', nodeid),({('test', nodeid): {'string': 'a'},
+            ('issue', '-1'): {'title': 'b'}}, []))
+
+    def testLinking(self):
+        self.assertEqual(self.parseForm({
+            'string': 'a',
+            'issue-1 at add@nosy': '1',
+            'issue-2 at link@superseder': 'issue-1',
+            }),
+            ({('test', None): {'string': 'a'},
+              ('issue', '-1'): {'nosy': ['1']},
+              ('issue', '-2'): {}
+             },
+             [('issue', '-2', 'superseder', [('issue', '-1')])
+             ]
+            )
+        )
+
+    def testLinkBadDesignator(self):
+        self.assertRaises(FormError, self.parseForm,
+            {'test-1 at link@link': 'blah'})
+        self.assertRaises(FormError, self.parseForm,
+            {'test-1 at link@link': 'issue'})
+
+    def testLinkNotLink(self):
+        self.assertRaises(FormError, self.parseForm,
+            {'test-1 at link@boolean': 'issue-1'})
+        self.assertRaises(FormError, self.parseForm,
+            {'test-1 at link@string': 'issue-1'})
+
+    def testBackwardsCompat(self):
+        res = self.parseForm({':note': 'spam'}, 'issue')
+        date = res[0][('msg', '-1')]['date']
+        self.assertEqual(res, ({('issue', None): {}, ('msg', '-1'):
+            {'content': 'spam', 'author': '1', 'date': date}},
+            [('issue', None, 'messages', [('msg', '-1')])]))
+        file = FileUpload('foo', 'foo.txt')
+        self.assertEqual(self.parseForm({':file': file}, 'issue'),
+            ({('issue', None): {}, ('file', '-1'): {'content': 'foo',
+            'name': 'foo.txt', 'type': 'text/plain'}},
+            [('issue', None, 'files', [('file', '-1')])]))
+
+def test_suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(FormTestCase))
+    suite.addTest(unittest.makeSuite(MessageTestCase))
+    return suite
+
+if __name__ == '__main__':
+    runner = unittest.TextTestRunner()
+    unittest.main(testRunner=runner)
+
+# vim: set filetype=python sts=4 sw=4 et si :

Added: tracker/vendor/roundup/current/test/test_dates.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/test/test_dates.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,402 @@
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+#
+# $Id: test_dates.py,v 1.38 2006/03/03 02:02:50 richard Exp $
+from __future__ import nested_scopes
+
+import unittest, time
+
+from roundup.date import Date, Interval, Range, fixTimeOverflow
+
+class DateTestCase(unittest.TestCase):
+    def testDateInterval(self):
+        ae = self.assertEqual
+        date = Date("2000-06-26.00:34:02 + 2d")
+        ae(str(date), '2000-06-28.00:34:02')
+        date = Date("2000-02-27 + 2d")
+        ae(str(date), '2000-02-29.00:00:00')
+        date = Date("2001-02-27 + 2d")
+        ae(str(date), '2001-03-01.00:00:00')
+
+    def testDate(self):
+        ae = self.assertEqual
+        date = Date("2000-04-17")
+        ae(str(date), '2000-04-17.00:00:00')
+        date = Date("2000/04/17")
+        ae(str(date), '2000-04-17.00:00:00')
+        date = Date("2000-4-7")
+        ae(str(date), '2000-04-07.00:00:00')
+        date = Date("2000-4-17")
+        ae(str(date), '2000-04-17.00:00:00')
+        date = Date("01-25")
+        y, m, d, x, x, x, x, x, x = time.gmtime(time.time())
+        ae(str(date), '%s-01-25.00:00:00'%y)
+        date = Date("2000-04-17.03:45")
+        ae(str(date), '2000-04-17.03:45:00')
+        date = Date("2000/04/17.03:45")
+        ae(str(date), '2000-04-17.03:45:00')
+        date = Date("08-13.22:13")
+        ae(str(date), '%s-08-13.22:13:00'%y)
+        date = Date("11-07.09:32:43")
+        ae(str(date), '%s-11-07.09:32:43'%y)
+        date = Date("14:25")
+        ae(str(date), '%s-%02d-%02d.14:25:00'%(y, m, d))
+        date = Date("8:47:11")
+        ae(str(date), '%s-%02d-%02d.08:47:11'%(y, m, d))
+        ae(str(Date('2003')), '2003-01-01.00:00:00')
+        ae(str(Date('2004-06')), '2004-06-01.00:00:00')
+
+    def testDateError(self):
+        self.assertRaises(ValueError, Date, "12")
+        # Date cannot handle dates before UNIX epoch
+        self.assertRaises(ValueError, Date, (1, 1, 1, 0, 0, 0.0, 0, 1, -1))
+        self.assertRaises(ValueError, Date, "1/1/06")
+
+    def testOffset(self):
+        ae = self.assertEqual
+        date = Date("2000-04-17", -5)
+        ae(str(date), '2000-04-17.05:00:00')
+        date = Date("01-25", -5)
+        y, m, d, x, x, x, x, x, x = time.gmtime(time.time())
+        ae(str(date), '%s-01-25.05:00:00'%y)
+        date = Date("2000-04-17.03:45", -5)
+        ae(str(date), '2000-04-17.08:45:00')
+        date = Date("08-13.22:13", -5)
+        ae(str(date), '%s-08-14.03:13:00'%y)
+        date = Date("11-07.09:32:43", -5)
+        ae(str(date), '%s-11-07.14:32:43'%y)
+        date = Date("14:25", -5)
+        ae(str(date), '%s-%02d-%02d.19:25:00'%(y, m, d))
+        date = Date("8:47:11", -5)
+        ae(str(date), '%s-%02d-%02d.13:47:11'%(y, m, d))
+
+        # just make sure we parse these, m'kay?
+        date = Date('-1d')
+        date = Date('-1w')
+        date = Date('-1m')
+        date = Date('-1y')
+
+    def testOffsetRandom(self):
+        ae = self.assertEqual
+        # XXX unsure of the usefulness of these, they're pretty random
+        date = Date('2000-01-01') + Interval('- 2y 2m')
+        ae(str(date), '1997-11-01.00:00:00')
+        date = Date('2000-01-01 - 2y 2m')
+        ae(str(date), '1997-11-01.00:00:00')
+        date = Date('2000-01-01') + Interval('2m')
+        ae(str(date), '2000-03-01.00:00:00')
+        date = Date('2000-01-01 + 2m')
+        ae(str(date), '2000-03-01.00:00:00')
+
+        date = Date('2000-01-01') + Interval('60d')
+        ae(str(date), '2000-03-01.00:00:00')
+        date = Date('2001-01-01') + Interval('60d')
+        ae(str(date), '2001-03-02.00:00:00')
+
+    def testOffsetAdd(self):
+        ae = self.assertEqual
+        date = Date('2000-02-28.23:59:59') + Interval('00:00:01')
+        ae(str(date), '2000-02-29.00:00:00')
+        date = Date('2001-02-28.23:59:59') + Interval('00:00:01')
+        ae(str(date), '2001-03-01.00:00:00')
+
+        date = Date('2000-02-28.23:58:59') + Interval('00:01:01')
+        ae(str(date), '2000-02-29.00:00:00')
+        date = Date('2001-02-28.23:58:59') + Interval('00:01:01')
+        ae(str(date), '2001-03-01.00:00:00')
+
+        date = Date('2000-02-28.22:58:59') + Interval('01:01:01')
+        ae(str(date), '2000-02-29.00:00:00')
+        date = Date('2001-02-28.22:58:59') + Interval('01:01:01')
+        ae(str(date), '2001-03-01.00:00:00')
+
+        date = Date('2000-02-28.22:58:59') + Interval('00:00:3661')
+        ae(str(date), '2000-02-29.00:00:00')
+        date = Date('2001-02-28.22:58:59') + Interval('00:00:3661')
+        ae(str(date), '2001-03-01.00:00:00')
+
+    def testOffsetSub(self):
+        ae = self.assertEqual
+        date = Date('2000-12-01') - Interval('- 1d')
+
+        date = Date('2000-01-01') - Interval('- 2y 2m')
+        ae(str(date), '2002-03-01.00:00:00')
+        date = Date('2000-01-01') - Interval('2m')
+        ae(str(date), '1999-11-01.00:00:00')
+
+        date = Date('2000-03-01') - Interval('60d')
+        ae(str(date), '2000-01-01.00:00:00')
+        date = Date('2001-03-02') - Interval('60d')
+        ae(str(date), '2001-01-01.00:00:00')
+
+        date = Date('2000-02-29.00:00:00') - Interval('00:00:01')
+        ae(str(date), '2000-02-28.23:59:59')
+        date = Date('2001-03-01.00:00:00') - Interval('00:00:01')
+        ae(str(date), '2001-02-28.23:59:59')
+
+        date = Date('2000-02-29.00:00:00') - Interval('00:01:01')
+        ae(str(date), '2000-02-28.23:58:59')
+        date = Date('2001-03-01.00:00:00') - Interval('00:01:01')
+        ae(str(date), '2001-02-28.23:58:59')
+
+        date = Date('2000-02-29.00:00:00') - Interval('01:01:01')
+        ae(str(date), '2000-02-28.22:58:59')
+        date = Date('2001-03-01.00:00:00') - Interval('01:01:01')
+        ae(str(date), '2001-02-28.22:58:59')
+
+        date = Date('2000-02-29.00:00:00') - Interval('00:00:3661')
+        ae(str(date), '2000-02-28.22:58:59')
+        date = Date('2001-03-01.00:00:00') - Interval('00:00:3661')
+        ae(str(date), '2001-02-28.22:58:59')
+
+    def testDateLocal(self):
+        ae = self.assertEqual
+        date = Date("02:42:20")
+        date = date.local(10)
+        y, m, d, x, x, x, x, x, x = time.gmtime(time.time())
+        ae(str(date), '%s-%02d-%02d.12:42:20'%(y, m, d))
+
+    def testIntervalInit(self):
+        ae = self.assertEqual
+        ae(str(Interval('3y')), '+ 3y')
+        ae(str(Interval('2 y 1 m')), '+ 2y 1m')
+        ae(str(Interval('1m 25d')), '+ 1m 25d')
+        ae(str(Interval('-2w 3 d ')), '- 17d')
+        ae(str(Interval(' - 1 d 2:50 ')), '- 1d 2:50')
+        ae(str(Interval(' 14:00 ')), '+ 14:00')
+        ae(str(Interval(' 0:04:33 ')), '+ 0:04:33')
+        ae(str(Interval(8.*3600)), '+ 8:00')
+
+    def testIntervalInitDate(self):
+        ae = self.assertEqual
+        now = Date('.')
+        now.hour = now.minute = now.second = 0
+        then = now + Interval('2d')
+        ae((Interval(str(then))), Interval('- 2d'))
+        then = now - Interval('2d')
+        ae(Interval(str(then)), Interval('+ 2d'))
+
+    def testIntervalAddMonthBoundary(self):
+        # force the transition over a month boundary
+        now = Date('2003-10-30.00:00:00')
+        then = now + Interval('2d')
+        self.assertEqual(str(then), '2003-11-01.00:00:00')
+        now = Date('2004-02-28.00:00:00')
+        then = now + Interval('1d')
+        self.assertEqual(str(then), '2004-02-29.00:00:00')
+        now = Date('2003-02-28.00:00:00')
+        then = now + Interval('1d')
+        self.assertEqual(str(then), '2003-03-01.00:00:00')
+        now = Date('2003-01-01.00:00:00')
+        then = now + Interval('59d')
+        self.assertEqual(str(then), '2003-03-01.00:00:00')
+        now = Date('2004-01-01.00:00:00')
+        then = now + Interval('59d')
+        self.assertEqual(str(then), '2004-02-29.00:00:00')
+
+    def testIntervalSubtractMonthBoundary(self):
+        # force the transition over a month boundary
+        now = Date('2003-11-01.00:00:00')
+        then = now - Interval('2d')
+        self.assertEqual(str(then), '2003-10-30.00:00:00')
+        now = Date('2004-02-29.00:00:00')
+        then = now - Interval('1d')
+        self.assertEqual(str(then), '2004-02-28.00:00:00')
+        now = Date('2003-03-01.00:00:00')
+        then = now - Interval('1d')
+        self.assertEqual(str(then), '2003-02-28.00:00:00')
+        now = Date('2003-03-01.00:00:00')
+        then = now - Interval('59d')
+        self.assertEqual(str(then), '2003-01-01.00:00:00')
+        now = Date('2004-02-29.00:00:00')
+        then = now - Interval('59d')
+        self.assertEqual(str(then), '2004-01-01.00:00:00')
+
+    def testIntervalAddYearBoundary(self):
+        # force the transition over a year boundary
+        now = Date('2003-12-30.00:00:00')
+        then = now + Interval('2d')
+        self.assertEqual(str(then), '2004-01-01.00:00:00')
+        now = Date('2003-01-01.00:00:00')
+        then = now + Interval('365d')
+        self.assertEqual(str(then), '2004-01-01.00:00:00')
+        now = Date('2004-01-01.00:00:00')
+        then = now + Interval('366d')
+        self.assertEqual(str(then), '2005-01-01.00:00:00')
+
+    def testIntervalSubtractYearBoundary(self):
+        # force the transition over a year boundary
+        now = Date('2003-01-01.00:00:00')
+        then = now - Interval('2d')
+        self.assertEqual(str(then), '2002-12-30.00:00:00')
+        now = Date('2004-02-01.00:00:00')
+        then = now - Interval('365d')
+        self.assertEqual(str(then), '2003-02-01.00:00:00')
+        now = Date('2005-02-01.00:00:00')
+        then = now - Interval('365d')
+        self.assertEqual(str(then), '2004-02-02.00:00:00')
+
+    def testDateSubtract(self):
+        # These are thoroughly broken right now.
+        i = Date('2003-03-15.00:00:00') - Date('2003-03-10.00:00:00')
+        self.assertEqual(i, Interval('5d'))
+        i = Date('2003-02-01.00:00:00') - Date('2003-03-01.00:00:00')
+        self.assertEqual(i, Interval('-28d'))
+        i = Date('2003-03-01.00:00:00') - Date('2003-02-01.00:00:00')
+        self.assertEqual(i, Interval('28d'))
+        i = Date('2003-03-03.00:00:00') - Date('2003-02-01.00:00:00')
+        self.assertEqual(i, Interval('30d'))
+        i = Date('2003-03-03.00:00:00') - Date('2002-02-01.00:00:00')
+        self.assertEqual(i, Interval('395d'))
+        i = Date('2003-03-03.00:00:00') - Date('2003-04-01.00:00:00')
+        self.assertEqual(i, Interval('-29d'))
+        i = Date('2003-03-01.00:00:00') - Date('2003-02-01.00:00:00')
+        self.assertEqual(i, Interval('28d'))
+        # force the transition over a year boundary
+        i = Date('2003-01-01.00:00:00') - Date('2002-01-01.00:00:00')
+        self.assertEqual(i, Interval('365d'))
+
+    def testIntervalAdd(self):
+        ae = self.assertEqual
+        ae(str(Interval('1y') + Interval('1y')), '+ 2y')
+        ae(str(Interval('1y') + Interval('1m')), '+ 1y 1m')
+        ae(str(Interval('1y') + Interval('2:40')), '+ 1y 2:40')
+        ae(str(Interval('1y') + Interval('- 1y')), '00:00')
+        ae(str(Interval('- 1y') + Interval('1y')), '00:00')
+        ae(str(Interval('- 1y') + Interval('- 1y')), '- 2y')
+        ae(str(Interval('1y') + Interval('- 1m')), '+ 11m')
+        ae(str(Interval('1:00') + Interval('1:00')), '+ 2:00')
+        ae(str(Interval('0:50') + Interval('0:50')), '+ 1:40')
+        ae(str(Interval('1:50') + Interval('- 1:50')), '00:00')
+        ae(str(Interval('- 1:50') + Interval('1:50')), '00:00')
+        ae(str(Interval('- 1:50') + Interval('- 1:50')), '- 3:40')
+        ae(str(Interval('1:59:59') + Interval('00:00:01')), '+ 2:00')
+        ae(str(Interval('2:00') + Interval('- 00:00:01')), '+ 1:59:59')
+
+    def testIntervalSub(self):
+        ae = self.assertEqual
+        ae(str(Interval('1y') - Interval('- 1y')), '+ 2y')
+        ae(str(Interval('1y') - Interval('- 1m')), '+ 1y 1m')
+        ae(str(Interval('1y') - Interval('- 2:40')), '+ 1y 2:40')
+        ae(str(Interval('1y') - Interval('1y')), '00:00')
+        ae(str(Interval('1y') - Interval('1m')), '+ 11m')
+        ae(str(Interval('1:00') - Interval('- 1:00')), '+ 2:00')
+        ae(str(Interval('0:50') - Interval('- 0:50')), '+ 1:40')
+        ae(str(Interval('1:50') - Interval('1:50')), '00:00')
+        ae(str(Interval('1:59:59') - Interval('- 00:00:01')), '+ 2:00')
+        ae(str(Interval('2:00') - Interval('00:00:01')), '+ 1:59:59')
+
+    def testOverflow(self):
+        ae = self.assertEqual
+        ae(fixTimeOverflow((1,0,0,0, 0, 0, 60)), (1,0,0,0, 0, 1, 0))
+        ae(fixTimeOverflow((1,0,0,0, 0, 0, 100)), (1,0,0,0, 0, 1, 40))
+        ae(fixTimeOverflow((1,0,0,0, 0, 0, 60*60)), (1,0,0,0, 1, 0, 0))
+        ae(fixTimeOverflow((1,0,0,0, 0, 0, 24*60*60)), (1,0,0,1, 0, 0, 0))
+        ae(fixTimeOverflow((1,0,0,0, 0, 0, -1)), (-1,0,0,0, 0, 0, 1))
+        ae(fixTimeOverflow((1,0,0,0, 0, 0, -100)), (-1,0,0,0, 0, 1, 40))
+        ae(fixTimeOverflow((1,0,0,0, 0, 0, -60*60)), (-1,0,0,0, 1, 0, 0))
+        ae(fixTimeOverflow((1,0,0,0, 0, 0, -24*60*60)), (-1,0,0,1, 0, 0, 0))
+        ae(fixTimeOverflow((-1,0,0,0, 0, 0, 1)), (-1,0,0,0, 0, 0, 1))
+        ae(fixTimeOverflow((-1,0,0,0, 0, 0, 100)), (-1,0,0,0, 0, 1, 40))
+        ae(fixTimeOverflow((-1,0,0,0, 0, 0, 60*60)), (-1,0,0,0, 1, 0, 0))
+        ae(fixTimeOverflow((-1,0,0,0, 0, 0, 24*60*60)), (-1,0,0,1, 0, 0, 0))
+
+    def testDivision(self):
+        ae = self.assertEqual
+        ae(str(Interval('1y')/2), '+ 6m')
+        ae(str(Interval('1:00')/2), '+ 0:30')
+        ae(str(Interval('00:01')/2), '+ 0:00:30')
+
+    def testSorting(self):
+        ae = self.assertEqual
+        i1 = Interval('1y')
+        i2 = Interval('1d')
+        l = [i1, i2]; l.sort()
+        ae(l, [i2, i1])
+        l = [i2, i1]; l.sort()
+        ae(l, [i2, i1])
+        i1 = Interval('- 2d')
+        i2 = Interval('1d')
+        l = [i1, i2]; l.sort()
+        ae(l, [i1, i2])
+
+        i1 = Interval("1:20")
+        i2 = Interval("2d")
+        i3 = Interval("3:30")
+        l = [i1, i2, i3]; l.sort()
+        ae(l, [i1, i3, i2])
+
+    def testGranularity(self):
+        ae = self.assertEqual
+        ae(str(Date('2003-2-12', add_granularity=1)), '2003-02-12.23:59:59')
+        ae(str(Date('2003-1-1.23:00', add_granularity=1)), '2003-01-01.23:00:59')
+        ae(str(Date('2003', add_granularity=1)), '2003-12-31.23:59:59')
+        ae(str(Date('2003-5', add_granularity=1)), '2003-05-31.23:59:59')
+        ae(str(Interval('+1w', add_granularity=1)), '+ 14d')
+        ae(str(Interval('-2m 3w', add_granularity=1)), '- 2m 14d')
+
+    def testIntervalPretty(self):
+        def ae(spec, pretty):
+            self.assertEqual(Interval(spec).pretty(), pretty)
+        ae('2y', 'in 2 years')
+        ae('1y', 'in 1 year')
+        ae('2m', 'in 2 months')
+        ae('1m 30d', 'in 2 months')
+        ae('60d', 'in 2 months')
+        ae('59d', 'in 1 month')
+        ae('1m', 'in 1 month')
+        ae('29d', 'in 1 month')
+        ae('28d', 'in 4 weeks')
+        ae('8d', 'in 1 week')
+        ae('7d', 'in 7 days')
+        ae('1w', 'in 7 days')
+        ae('2d', 'in 2 days')
+        ae('1d', 'tomorrow')
+        ae('02:00:00', 'in 2 hours')
+        ae('01:59:00', 'in 1 3/4 hours')
+        ae('01:45:00', 'in 1 3/4 hours')
+        ae('01:30:00', 'in 1 1/2 hours')
+        ae('01:29:00', 'in 1 1/4 hours')
+        ae('01:00:00', 'in an hour')
+        ae('00:30:00', 'in 1/2 an hour')
+        ae('00:15:00', 'in 1/4 hour')
+        ae('00:02:00', 'in 2 minutes')
+        ae('00:01:00', 'in 1 minute')
+        ae('00:00:30', 'in a moment')
+        ae('-00:00:30', 'just now')
+        ae('-1d', 'yesterday')
+        ae('-1y', '1 year ago')
+        ae('-2y', '2 years ago')
+
+    def testPyDatetime(self):
+        try:
+            import datetime
+        except:
+            return
+        d = datetime.datetime.now()
+        Date(d)
+
+def test_suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(DateTestCase))
+    return suite
+
+if __name__ == '__main__':
+    runner = unittest.TextTestRunner()
+    unittest.main(testRunner=runner)
+
+# vim: set filetype=python sts=4 sw=4 et si :

Added: tracker/vendor/roundup/current/test/test_hyperdbvals.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/test/test_hyperdbvals.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,129 @@
+#
+# Copyright (c) 2003 Richard Jones, richard at commonground.com.au
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# This module 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.
+#
+# $Id: test_hyperdbvals.py,v 1.1 2003/11/11 00:35:14 richard Exp $
+
+import unittest, os, shutil, errno, sys, difflib, cgi, re, sha
+
+from roundup import init, instance, password, hyperdb, date
+
+class TestClass:
+    def getprops(self):
+        return {
+            'string': hyperdb.String(),
+            'number': hyperdb.Number(),
+            'boolean': hyperdb.Boolean(),
+            'password': hyperdb.Password(),
+            'date': hyperdb.Date(),
+            'interval': hyperdb.Interval(),
+            'link': hyperdb.Link('test'),
+            'link2': hyperdb.Link('test2'),
+            'multilink': hyperdb.Multilink('test'),
+            'multilink2': hyperdb.Multilink('test2'),
+        }
+    def getkey(self):
+        return 'string'
+    def lookup(self, value):
+        if value == 'valid':
+            return '1'
+        raise KeyError
+    def get(self, nodeid, propname):
+        assert propname.startswith('multilink')
+        assert nodeid is not None
+        return ['2', '3']
+
+class TestClass2:
+    def properties(self):
+        return {
+            'string': hyperdb.String(),
+        }
+    def getkey(self):
+        return None
+    def labelprop(self, default_to_id=1):
+        return 'id'
+
+class TestDatabase:
+    classes = {'test': TestClass(), 'test2': TestClass2()}
+    def getUserTimezone(self):
+        return 0
+
+class RawToHyperdbTest(unittest.TestCase):
+    def _test(self, propname, value, itemid=None):
+        return hyperdb.rawToHyperdb(TestDatabase(), TestClass(), itemid,
+            propname, value)
+    def testString(self):
+        self.assertEqual(self._test('string', '  a string '), 'a string')
+    def testNumber(self):
+        self.assertEqual(self._test('number', '  10 '), 10)
+        self.assertEqual(self._test('number', '  1.5 '), 1.5)
+    def testBoolean(self):
+        for true in 'yes true on 1'.split():
+            self.assertEqual(self._test('boolean', '  %s '%true), 1)
+        for false in 'no false off 0'.split():
+            self.assertEqual(self._test('boolean', '  %s '%false), 0)
+    def testPassword(self):
+        self.assertEqual(self._test('password', '  a string '), 'a string')
+        val = self._test('password', '  a string ')
+        self.assert_(isinstance(val, password.Password))
+        val = self._test('password', '{plaintext}a string')
+        self.assert_(isinstance(val, password.Password))
+        val = self._test('password', '{crypt}a string')
+        self.assert_(isinstance(val, password.Password))
+        s = sha.sha('a string').hexdigest()
+        val = self._test('password', '{SHA}'+s)
+        self.assert_(isinstance(val, password.Password))
+        self.assertEqual(val, 'a string')
+        self.assertRaises(hyperdb.HyperdbValueError, self._test,
+            'password', '{fubar}a string')
+    def testDate(self):
+        val = self._test('date', ' 2003-01-01  ')
+        self.assert_(isinstance(val, date.Date))
+        val = self._test('date', ' 2003/01/01  ')
+        self.assert_(isinstance(val, date.Date))
+        val = self._test('date', ' 2003/1/1  ')
+        self.assert_(isinstance(val, date.Date))
+        val = self._test('date', ' 2003-1-1  ')
+        self.assert_(isinstance(val, date.Date))
+        self.assertRaises(hyperdb.HyperdbValueError, self._test, 'date',
+            'fubar')
+    def testInterval(self):
+        val = self._test('interval', ' +1d  ')
+        self.assert_(isinstance(val, date.Interval))
+        self.assertRaises(hyperdb.HyperdbValueError, self._test, 'interval',
+            'fubar')
+    def testLink(self):
+        self.assertEqual(self._test('link', '1'), '1')
+        self.assertEqual(self._test('link', 'valid'), '1')
+        self.assertRaises(hyperdb.HyperdbValueError, self._test, 'link',
+            'invalid')
+    def testMultilink(self):
+        self.assertEqual(self._test('multilink', '', '1'), [])
+        self.assertEqual(self._test('multilink', '1', '1'), ['1'])
+        self.assertEqual(self._test('multilink', 'valid', '1'), ['1'])
+        self.assertRaises(hyperdb.HyperdbValueError, self._test, 'multilink',
+            'invalid', '1')
+        self.assertEqual(self._test('multilink', '+1', '1'), ['1', '2', '3'])
+        self.assertEqual(self._test('multilink', '+valid', '1'), ['1', '2',
+            '3'])
+        self.assertEqual(self._test('multilink', '+1,-2', '1'), ['1', '3'])
+        self.assertEqual(self._test('multilink', '+valid,-3', '1'), ['1', '2'])
+        self.assertEqual(self._test('multilink', '+1', None), ['1'])
+        self.assertEqual(self._test('multilink', '+valid', None), ['1'])
+        self.assertEqual(self._test('multilink', '', None), [])
+
+def test_suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(RawToHyperdbTest))
+    return suite
+
+if __name__ == '__main__':
+    runner = unittest.TextTestRunner()
+    unittest.main(testRunner=runner)
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/test/test_indexer.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/test/test_indexer.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,93 @@
+# Copyright (c) 2002 ekit.com Inc (http://www.ekit-inc.com/)
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+#   The above copyright notice and this permission notice shall be included in
+#   all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+# $Id: test_indexer.py,v 1.10 2006/02/07 04:59:05 richard Exp $
+
+import os, unittest, shutil
+
+class db:
+    class config(dict):
+        DATABASE = 'test-index'
+    config = config()
+    config[('main', 'indexer_stopwords')] = []
+
+class IndexerTest(unittest.TestCase):
+    def setUp(self):
+        if os.path.exists('test-index'):
+            shutil.rmtree('test-index')
+        os.mkdir('test-index')
+        os.mkdir('test-index/files')
+        from roundup.backends.indexer_dbm import Indexer
+        self.dex = Indexer(db)
+        self.dex.load_index()
+
+    def test_basics(self):
+        self.dex.add_text(('test', '1', 'foo'), 'a the hello world')
+        self.dex.add_text(('test', '2', 'foo'), 'blah blah the world')
+        self.assertEqual(self.dex.find(['world']), [('test', '1', 'foo'),
+                                                    ('test', '2', 'foo')])
+        self.assertEqual(self.dex.find(['blah']), [('test', '2', 'foo')])
+        self.assertEqual(self.dex.find(['blah', 'hello']), [])
+
+    def test_change(self):
+        self.dex.add_text(('test', '1', 'foo'), 'a the hello world')
+        self.dex.add_text(('test', '2', 'foo'), 'blah blah the world')
+        self.assertEqual(self.dex.find(['world']), [('test', '1', 'foo'),
+                                                    ('test', '2', 'foo')])
+        self.dex.add_text(('test', '1', 'foo'), 'a the hello')
+        self.assertEqual(self.dex.find(['world']), [('test', '2', 'foo')])
+
+    def test_clear(self):
+        self.dex.add_text(('test', '1', 'foo'), 'a the hello world')
+        self.dex.add_text(('test', '2', 'foo'), 'blah blah the world')
+        self.assertEqual(self.dex.find(['world']), [('test', '1', 'foo'),
+                                                    ('test', '2', 'foo')])
+        self.dex.add_text(('test', '1', 'foo'), '')
+        self.assertEqual(self.dex.find(['world']), [('test', '2', 'foo')])
+
+    def tearDown(self):
+        shutil.rmtree('test-index')
+
+class XapianIndexerTest(IndexerTest):
+    def setUp(self):
+        if os.path.exists('test-index'):
+            shutil.rmtree('test-index')
+        os.mkdir('test-index')
+        from roundup.backends.indexer_xapian import Indexer
+        self.dex = Indexer(db)
+    def tearDown(self):
+        shutil.rmtree('test-index')
+
+def test_suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(IndexerTest))
+    try:
+        import xapian
+        suite.addTest(unittest.makeSuite(XapianIndexerTest))
+    except ImportError:
+        print "Skipping Xapian indexer tests"
+        pass
+    return suite
+
+if __name__ == '__main__':
+    runner = unittest.TextTestRunner()
+    unittest.main(testRunner=runner)
+
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/test/test_locking.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/test/test_locking.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,58 @@
+# Copyright (c) 2002 ekit.com Inc (http://www.ekit-inc.com/)
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+#   The above copyright notice and this permission notice shall be included in
+#   all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+# $Id: test_locking.py,v 1.4 2003/10/25 22:53:26 richard Exp $
+
+import os, unittest, tempfile
+
+from roundup.backends.locking import acquire_lock, release_lock
+
+class LockingTest(unittest.TestCase):
+    def setUp(self):
+        self.path = tempfile.mktemp()
+        open(self.path, 'w').write('hi\n')
+
+    # XXX test disabled because it simply doesn't work on many platforms
+    # (Solaris and Irix are known to fail, but Linux works)
+    def xtest_basics(self):
+        f = acquire_lock(self.path)
+        try:
+            acquire_lock(self.path, block=0)
+        except:
+            pass
+        else:
+            raise AssertionError, 'no exception'
+        release_lock(f)
+        f = acquire_lock(self.path)
+        release_lock(f)
+
+    def tearDown(self):
+        os.remove(self.path)
+
+def test_suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(LockingTest))
+    return suite
+
+if __name__ == '__main__':
+    runner = unittest.TextTestRunner()
+    unittest.main(testRunner=runner)
+
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/test/test_mailgw.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/test/test_mailgw.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,1286 @@
+#
+# Copyright (c) 2001 Richard Jones, richard at bofh.asn.au.
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# This module 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.
+#
+# $Id: test_mailgw.py,v 1.77 2006/03/02 23:45:23 richard Exp $
+
+# TODO: test bcc
+
+import unittest, tempfile, os, shutil, errno, imp, sys, difflib, rfc822, time
+
+from cStringIO import StringIO
+
+if not os.environ.has_key('SENDMAILDEBUG'):
+    os.environ['SENDMAILDEBUG'] = 'mail-test.log'
+SENDMAILDEBUG = os.environ['SENDMAILDEBUG']
+
+from roundup.mailgw import MailGW, Unauthorized, uidFromAddress, \
+    parseContent, IgnoreLoop, IgnoreBulk, MailUsageError
+from roundup import init, instance, password, rfc2822, __version__
+
+import db_test_base
+
+class Message(rfc822.Message):
+    """String-based Message class with equivalence test."""
+    def __init__(self, s):
+        rfc822.Message.__init__(self, StringIO(s.strip()))
+
+    def __eq__(self, other):
+        return (self.dict == other.dict and
+                self.fp.read() == other.fp.read())
+
+class DiffHelper:
+    def compareMessages(self, new, old):
+        """Compare messages for semantic equivalence."""
+        new, old = Message(new), Message(old)
+        del new['date'], old['date']
+
+        if not new == old:
+            res = []
+
+            for key in new.keys():
+                if key.lower() == 'x-roundup-version':
+                    # version changes constantly, so handle it specially
+                    if new[key] != __version__:
+                        res.append('  %s: %s != %s' % (key, __version__,
+                            new[key]))
+                elif new[key] != old[key]:
+                    res.append('  %s: %s != %s' % (key, old[key], new[key]))
+
+            body_diff = self.compareStrings(new.fp.read(), old.fp.read())
+            if body_diff:
+                res.append('')
+                res.extend(body_diff)
+
+            if res:
+                res.insert(0, 'Generated message not correct (diff follows):')
+                raise AssertionError, '\n'.join(res)
+
+    def compareStrings(self, s2, s1):
+        '''Note the reversal of s2 and s1 - difflib.SequenceMatcher wants
+           the first to be the "original" but in the calls in this file,
+           the second arg is the original. Ho hum.
+        '''
+        l1 = s1.strip().split('\n')
+        l2 = s2.strip().split('\n')
+        if l1 == l2:
+            return
+        s = difflib.SequenceMatcher(None, l1, l2)
+        res = []
+        for value, s1s, s1e, s2s, s2e in s.get_opcodes():
+            if value == 'equal':
+                for i in range(s1s, s1e):
+                    res.append('  %s'%l1[i])
+            elif value == 'delete':
+                for i in range(s1s, s1e):
+                    res.append('- %s'%l1[i])
+            elif value == 'insert':
+                for i in range(s2s, s2e):
+                    res.append('+ %s'%l2[i])
+            elif value == 'replace':
+                for i, j in zip(range(s1s, s1e), range(s2s, s2e)):
+                    res.append('- %s'%l1[i])
+                    res.append('+ %s'%l2[j])
+
+        return res
+
+class MailgwTestCase(unittest.TestCase, DiffHelper):
+    count = 0
+    schema = 'classic'
+    def setUp(self):
+        MailgwTestCase.count = MailgwTestCase.count + 1
+        self.dirname = '_test_mailgw_%s'%self.count
+        # set up and open a tracker
+        self.instance = db_test_base.setupTracker(self.dirname)
+
+        # and open the database
+        self.db = self.instance.open('admin')
+        self.chef_id = self.db.user.create(username='Chef',
+            address='chef at bork.bork.bork', realname='Bork, Chef', roles='User')
+        self.richard_id = self.db.user.create(username='richard',
+            address='richard at test', roles='User')
+        self.mary_id = self.db.user.create(username='mary', address='mary at test',
+            roles='User', realname='Contrary, Mary')
+        self.john_id = self.db.user.create(username='john', address='john at test',
+            alternate_addresses='jondoe at test\njohn.doe at test', roles='User',
+            realname='John Doe')
+
+    def tearDown(self):
+        if os.path.exists(SENDMAILDEBUG):
+            os.remove(SENDMAILDEBUG)
+        self.db.close()
+        try:
+            shutil.rmtree(self.dirname)
+        except OSError, error:
+            if error.errno not in (errno.ENOENT, errno.ESRCH): raise
+
+    def _handle_mail(self, message):
+        handler = self.instance.MailGW(self.instance, self.db)
+        handler.trapExceptions = 0
+        ret = handler.main(StringIO(message))
+        # handler can close the db on us and open a new one
+        self.db = handler.db
+        return ret
+
+    def _get_mail(self):
+        f = open(SENDMAILDEBUG)
+        try:
+            return f.read()
+        finally:
+            f.close()
+
+    def testEmptyMessage(self):
+        nodeid = self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef at bork.bork.bork>
+To: issue_tracker at your.tracker.email.domain.example
+Cc: richard at test
+Reply-To: chef at bork.bork.bork
+Message-Id: <dummy_test_message_id>
+Subject: [issue] Testing...
+
+''')
+        assert not os.path.exists(SENDMAILDEBUG)
+        self.assertEqual(self.db.issue.get(nodeid, 'title'), 'Testing...')
+
+    def doNewIssue(self):
+        nodeid = self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef at bork.bork.bork>
+To: issue_tracker at your.tracker.email.domain.example
+Cc: richard at test
+Message-Id: <dummy_test_message_id>
+Subject: [issue] Testing...
+
+This is a test submission of a new issue.
+''')
+        assert not os.path.exists(SENDMAILDEBUG)
+        l = self.db.issue.get(nodeid, 'nosy')
+        l.sort()
+        self.assertEqual(l, [self.chef_id, self.richard_id])
+        return nodeid
+
+    def testNewIssue(self):
+        self.doNewIssue()
+
+    def testNewIssueNosy(self):
+        self.instance.config.ADD_AUTHOR_TO_NOSY = 'yes'
+        nodeid = self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef at bork.bork.bork>
+To: issue_tracker at your.tracker.email.domain.example
+Cc: richard at test
+Message-Id: <dummy_test_message_id>
+Subject: [issue] Testing...
+
+This is a test submission of a new issue.
+''')
+        assert not os.path.exists(SENDMAILDEBUG)
+        l = self.db.issue.get(nodeid, 'nosy')
+        l.sort()
+        self.assertEqual(l, [self.chef_id, self.richard_id])
+
+    def testAlternateAddress(self):
+        self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: John Doe <john.doe at test>
+To: issue_tracker at your.tracker.email.domain.example
+Message-Id: <dummy_test_message_id>
+Subject: [issue] Testing...
+
+This is a test submission of a new issue.
+''')
+        userlist = self.db.user.list()
+        assert not os.path.exists(SENDMAILDEBUG)
+        self.assertEqual(userlist, self.db.user.list(),
+            "user created when it shouldn't have been")
+
+    def testNewIssueNoClass(self):
+        self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef at bork.bork.bork>
+To: issue_tracker at your.tracker.email.domain.example
+Cc: richard at test
+Message-Id: <dummy_test_message_id>
+Subject: Testing...
+
+This is a test submission of a new issue.
+''')
+        assert not os.path.exists(SENDMAILDEBUG)
+
+    def testNewIssueAuthMsg(self):
+        # TODO: fix the damn config - this is apalling
+        self.db.config.MESSAGES_TO_AUTHOR = 'yes'
+        self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef at bork.bork.bork>
+To: issue_tracker at your.tracker.email.domain.example
+Message-Id: <dummy_test_message_id>
+Subject: [issue] Testing... [nosy=mary; assignedto=richard]
+
+This is a test submission of a new issue.
+''')
+        self.compareMessages(self._get_mail(),
+'''FROM: roundup-admin at your.tracker.email.domain.example
+TO: chef at bork.bork.bork, mary at test, richard at test
+Content-Type: text/plain; charset=utf-8
+Subject: [issue1] Testing...
+To: chef at bork.bork.bork, mary at test, richard at test
+From: "Bork, Chef" <issue_tracker at your.tracker.email.domain.example>
+Reply-To: Roundup issue tracker <issue_tracker at your.tracker.email.domain.example>
+MIME-Version: 1.0
+Message-Id: <dummy_test_message_id>
+X-Roundup-Name: Roundup issue tracker
+X-Roundup-Loop: hello
+Content-Transfer-Encoding: quoted-printable
+
+
+New submission from Bork, Chef <chef at bork.bork.bork>:
+
+This is a test submission of a new issue.
+
+----------
+assignedto: richard
+messages: 1
+nosy: Chef, mary, richard
+status: unread
+title: Testing...
+
+_______________________________________________________________________
+Roundup issue tracker <issue_tracker at your.tracker.email.domain.example>
+<http://tracker.example/cgi-bin/roundup.cgi/bugs/issue1>
+_______________________________________________________________________
+''')
+
+    # BUG
+    # def testMultipart(self):
+    #         '''With more than one part'''
+    #        see MultipartEnc tests: but if there is more than one part
+    #        we return a multipart/mixed and the boundary contains
+    #        the ip address of the test machine.
+
+    # BUG should test some binary attamchent too.
+
+    def testSimpleFollowup(self):
+        self.doNewIssue()
+        self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: mary <mary at test>
+To: issue_tracker at your.tracker.email.domain.example
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+Subject: [issue1] Testing...
+
+This is a second followup
+''')
+        self.compareMessages(self._get_mail(),
+'''FROM: roundup-admin at your.tracker.email.domain.example
+TO: chef at bork.bork.bork, richard at test
+Content-Type: text/plain; charset=utf-8
+Subject: [issue1] Testing...
+To: chef at bork.bork.bork, richard at test
+From: "Contrary, Mary" <issue_tracker at your.tracker.email.domain.example>
+Reply-To: Roundup issue tracker <issue_tracker at your.tracker.email.domain.example>
+MIME-Version: 1.0
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+X-Roundup-Name: Roundup issue tracker
+X-Roundup-Loop: hello
+Content-Transfer-Encoding: quoted-printable
+
+
+Contrary, Mary <mary at test> added the comment:
+
+This is a second followup
+
+----------
+status: unread -> chatting
+
+_______________________________________________________________________
+Roundup issue tracker <issue_tracker at your.tracker.email.domain.example>
+<http://tracker.example/cgi-bin/roundup.cgi/bugs/issue1>
+_______________________________________________________________________
+''')
+
+    def testFollowup(self):
+        self.doNewIssue()
+
+        self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: richard <richard at test>
+To: issue_tracker at your.tracker.email.domain.example
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+Subject: [issue1] Testing... [assignedto=mary; nosy=+john]
+
+This is a followup
+''')
+        l = self.db.issue.get('1', 'nosy')
+        l.sort()
+        self.assertEqual(l, [self.chef_id, self.richard_id, self.mary_id,
+            self.john_id])
+
+        self.compareMessages(self._get_mail(),
+'''FROM: roundup-admin at your.tracker.email.domain.example
+TO: chef at bork.bork.bork, john at test, mary at test
+Content-Type: text/plain; charset=utf-8
+Subject: [issue1] Testing...
+To: chef at bork.bork.bork, john at test, mary at test
+From: richard <issue_tracker at your.tracker.email.domain.example>
+Reply-To: Roundup issue tracker <issue_tracker at your.tracker.email.domain.example>
+MIME-Version: 1.0
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+X-Roundup-Name: Roundup issue tracker
+X-Roundup-Loop: hello
+Content-Transfer-Encoding: quoted-printable
+
+
+richard <richard at test> added the comment:
+
+This is a followup
+
+----------
+assignedto:  -> mary
+nosy: +john, mary
+status: unread -> chatting
+
+_______________________________________________________________________
+Roundup issue tracker <issue_tracker at your.tracker.email.domain.example>
+<http://tracker.example/cgi-bin/roundup.cgi/bugs/issue1>
+_______________________________________________________________________
+''')
+
+
+    #
+    # FOLLOWUP TITLE MATCH
+    #
+    def testFollowupTitleMatch(self):
+        self.doNewIssue()
+        self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: richard <richard at test>
+To: issue_tracker at your.tracker.email.domain.example
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+Subject: Re: Testing... [assignedto=mary; nosy=+john]
+
+This is a followup
+''')
+        self.compareMessages(self._get_mail(),
+'''FROM: roundup-admin at your.tracker.email.domain.example
+TO: chef at bork.bork.bork, john at test, mary at test
+Content-Type: text/plain; charset=utf-8
+Subject: [issue1] Testing...
+To: chef at bork.bork.bork, john at test, mary at test
+From: richard <issue_tracker at your.tracker.email.domain.example>
+Reply-To: Roundup issue tracker <issue_tracker at your.tracker.email.domain.example>
+MIME-Version: 1.0
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+X-Roundup-Name: Roundup issue tracker
+X-Roundup-Loop: hello
+Content-Transfer-Encoding: quoted-printable
+
+
+richard <richard at test> added the comment:
+
+This is a followup
+
+----------
+assignedto:  -> mary
+nosy: +john, mary
+status: unread -> chatting
+
+_______________________________________________________________________
+Roundup issue tracker <issue_tracker at your.tracker.email.domain.example>
+<http://tracker.example/cgi-bin/roundup.cgi/bugs/issue1>
+_______________________________________________________________________
+''')
+
+    def testFollowupTitleMatchNever(self):
+        nodeid = self.doNewIssue()
+        self.db.config.MAILGW_SUBJECT_CONTENT_MATCH = 'never'
+        self.assertNotEqual(self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: richard <richard at test>
+To: issue_tracker at your.tracker.email.domain.example
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+Subject: Re: Testing...
+
+This is a followup
+'''), nodeid)
+
+    def testFollowupTitleMatchNever(self):
+        nodeid = self.doNewIssue()
+        # force failure of the interval
+        time.sleep(2)
+        self.db.config.MAILGW_SUBJECT_CONTENT_MATCH = 'creation 00:00:01'
+        self.assertNotEqual(self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: richard <richard at test>
+To: issue_tracker at your.tracker.email.domain.example
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+Subject: Re: Testing...
+
+This is a followup
+'''), nodeid)
+        # now try a longer interval
+        self.db.config.MAILGW_SUBJECT_CONTENT_MATCH = 'creation +1d'
+        self.assertEqual(self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: richard <richard at test>
+To: issue_tracker at your.tracker.email.domain.example
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+Subject: Re: Testing...
+
+This is a followup
+'''), nodeid)
+
+
+    def testFollowupNosyAuthor(self):
+        self.doNewIssue()
+        self.db.config.ADD_AUTHOR_TO_NOSY = 'yes'
+        self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: john at test
+To: issue_tracker at your.tracker.email.domain.example
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+Subject: [issue1] Testing...
+
+This is a followup
+''')
+
+        self.compareMessages(self._get_mail(),
+'''FROM: roundup-admin at your.tracker.email.domain.example
+TO: chef at bork.bork.bork, richard at test
+Content-Type: text/plain; charset=utf-8
+Subject: [issue1] Testing...
+To: chef at bork.bork.bork, richard at test
+From: John Doe <issue_tracker at your.tracker.email.domain.example>
+Reply-To: Roundup issue tracker <issue_tracker at your.tracker.email.domain.example>
+MIME-Version: 1.0
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+X-Roundup-Name: Roundup issue tracker
+X-Roundup-Loop: hello
+Content-Transfer-Encoding: quoted-printable
+
+
+John Doe <john at test> added the comment:
+
+This is a followup
+
+----------
+nosy: +john
+status: unread -> chatting
+
+_______________________________________________________________________
+Roundup issue tracker <issue_tracker at your.tracker.email.domain.example>
+<http://tracker.example/cgi-bin/roundup.cgi/bugs/issue1>
+_______________________________________________________________________
+
+''')
+
+    def testFollowupNosyRecipients(self):
+        self.doNewIssue()
+        self.db.config.ADD_RECIPIENTS_TO_NOSY = 'yes'
+        self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: richard at test
+To: issue_tracker at your.tracker.email.domain.example
+Cc: john at test
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+Subject: [issue1] Testing...
+
+This is a followup
+''')
+        self.compareMessages(self._get_mail(),
+'''FROM: roundup-admin at your.tracker.email.domain.example
+TO: chef at bork.bork.bork
+Content-Type: text/plain; charset=utf-8
+Subject: [issue1] Testing...
+To: chef at bork.bork.bork
+From: richard <issue_tracker at your.tracker.email.domain.example>
+Reply-To: Roundup issue tracker <issue_tracker at your.tracker.email.domain.example>
+MIME-Version: 1.0
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+X-Roundup-Name: Roundup issue tracker
+X-Roundup-Loop: hello
+Content-Transfer-Encoding: quoted-printable
+
+
+richard <richard at test> added the comment:
+
+This is a followup
+
+----------
+nosy: +john
+status: unread -> chatting
+
+_______________________________________________________________________
+Roundup issue tracker <issue_tracker at your.tracker.email.domain.example>
+<http://tracker.example/cgi-bin/roundup.cgi/bugs/issue1>
+_______________________________________________________________________
+
+''')
+
+    def testFollowupNosyAuthorAndCopy(self):
+        self.doNewIssue()
+        self.db.config.ADD_AUTHOR_TO_NOSY = 'yes'
+        self.db.config.MESSAGES_TO_AUTHOR = 'yes'
+        self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: john at test
+To: issue_tracker at your.tracker.email.domain.example
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+Subject: [issue1] Testing...
+
+This is a followup
+''')
+        self.compareMessages(self._get_mail(),
+'''FROM: roundup-admin at your.tracker.email.domain.example
+TO: chef at bork.bork.bork, john at test, richard at test
+Content-Type: text/plain; charset=utf-8
+Subject: [issue1] Testing...
+To: chef at bork.bork.bork, john at test, richard at test
+From: John Doe <issue_tracker at your.tracker.email.domain.example>
+Reply-To: Roundup issue tracker <issue_tracker at your.tracker.email.domain.example>
+MIME-Version: 1.0
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+X-Roundup-Name: Roundup issue tracker
+X-Roundup-Loop: hello
+Content-Transfer-Encoding: quoted-printable
+
+
+John Doe <john at test> added the comment:
+
+This is a followup
+
+----------
+nosy: +john
+status: unread -> chatting
+
+_______________________________________________________________________
+Roundup issue tracker <issue_tracker at your.tracker.email.domain.example>
+<http://tracker.example/cgi-bin/roundup.cgi/bugs/issue1>
+_______________________________________________________________________
+
+''')
+
+    def testFollowupNoNosyAuthor(self):
+        self.doNewIssue()
+        self.instance.config.ADD_AUTHOR_TO_NOSY = 'no'
+        self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: john at test
+To: issue_tracker at your.tracker.email.domain.example
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+Subject: [issue1] Testing...
+
+This is a followup
+''')
+        self.compareMessages(self._get_mail(),
+'''FROM: roundup-admin at your.tracker.email.domain.example
+TO: chef at bork.bork.bork, richard at test
+Content-Type: text/plain; charset=utf-8
+Subject: [issue1] Testing...
+To: chef at bork.bork.bork, richard at test
+From: John Doe <issue_tracker at your.tracker.email.domain.example>
+Reply-To: Roundup issue tracker <issue_tracker at your.tracker.email.domain.example>
+MIME-Version: 1.0
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+X-Roundup-Name: Roundup issue tracker
+X-Roundup-Loop: hello
+Content-Transfer-Encoding: quoted-printable
+
+
+John Doe <john at test> added the comment:
+
+This is a followup
+
+----------
+status: unread -> chatting
+
+_______________________________________________________________________
+Roundup issue tracker <issue_tracker at your.tracker.email.domain.example>
+<http://tracker.example/cgi-bin/roundup.cgi/bugs/issue1>
+_______________________________________________________________________
+
+''')
+
+    def testFollowupNoNosyRecipients(self):
+        self.doNewIssue()
+        self.instance.config.ADD_RECIPIENTS_TO_NOSY = 'no'
+        self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: richard at test
+To: issue_tracker at your.tracker.email.domain.example
+Cc: john at test
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+Subject: [issue1] Testing...
+
+This is a followup
+''')
+        self.compareMessages(self._get_mail(),
+'''FROM: roundup-admin at your.tracker.email.domain.example
+TO: chef at bork.bork.bork
+Content-Type: text/plain; charset=utf-8
+Subject: [issue1] Testing...
+To: chef at bork.bork.bork
+From: richard <issue_tracker at your.tracker.email.domain.example>
+Reply-To: Roundup issue tracker <issue_tracker at your.tracker.email.domain.example>
+MIME-Version: 1.0
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+X-Roundup-Name: Roundup issue tracker
+X-Roundup-Loop: hello
+Content-Transfer-Encoding: quoted-printable
+
+
+richard <richard at test> added the comment:
+
+This is a followup
+
+----------
+status: unread -> chatting
+
+_______________________________________________________________________
+Roundup issue tracker <issue_tracker at your.tracker.email.domain.example>
+<http://tracker.example/cgi-bin/roundup.cgi/bugs/issue1>
+_______________________________________________________________________
+
+''')
+
+    def testFollowupEmptyMessage(self):
+        self.doNewIssue()
+
+        self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: richard <richard at test>
+To: issue_tracker at your.tracker.email.domain.example
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+Subject: [issue1] Testing... [assignedto=mary; nosy=+john]
+
+''')
+        l = self.db.issue.get('1', 'nosy')
+        l.sort()
+        self.assertEqual(l, [self.chef_id, self.richard_id, self.mary_id,
+            self.john_id])
+
+        # should be no file created (ie. no message)
+        assert not os.path.exists(SENDMAILDEBUG)
+
+    def testFollowupEmptyMessageNoSubject(self):
+        self.doNewIssue()
+
+        self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: richard <richard at test>
+To: issue_tracker at your.tracker.email.domain.example
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+Subject: [issue1] [assignedto=mary; nosy=+john]
+
+''')
+        l = self.db.issue.get('1', 'nosy')
+        l.sort()
+        self.assertEqual(l, [self.chef_id, self.richard_id, self.mary_id,
+            self.john_id])
+
+        # should be no file created (ie. no message)
+        assert not os.path.exists(SENDMAILDEBUG)
+
+    def testNosyRemove(self):
+        self.doNewIssue()
+
+        self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: richard <richard at test>
+To: issue_tracker at your.tracker.email.domain.example
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+Subject: [issue1] Testing... [nosy=-richard]
+
+''')
+        l = self.db.issue.get('1', 'nosy')
+        l.sort()
+        self.assertEqual(l, [self.chef_id])
+
+        # NO NOSY MESSAGE SHOULD BE SENT!
+        assert not os.path.exists(SENDMAILDEBUG)
+
+    def testNewUserAuthor(self):
+        # first without the permission
+        # heh... just ignore the API for a second ;)
+        self.db.security.role['anonymous'].permissions=[]
+        anonid = self.db.user.lookup('anonymous')
+        self.db.user.set(anonid, roles='Anonymous')
+
+        l = self.db.user.list()
+        l.sort()
+        message = '''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: fubar <fubar at bork.bork.bork>
+To: issue_tracker at your.tracker.email.domain.example
+Message-Id: <dummy_test_message_id>
+Subject: [issue] Testing...
+
+This is a test submission of a new issue.
+'''
+        self.assertRaises(Unauthorized, self._handle_mail, message)
+        m = self.db.user.list()
+        m.sort()
+        self.assertEqual(l, m)
+
+        # now with the permission
+        p = [
+            self.db.security.getPermission('Create', 'user'),
+            self.db.security.getPermission('Email Access', None),
+        ]
+        self.db.security.role['anonymous'].permissions=p
+        self._handle_mail(message)
+        m = self.db.user.list()
+        m.sort()
+        self.assertNotEqual(l, m)
+
+    def testEnc01(self):
+        self.doNewIssue()
+        self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: mary <mary at test>
+To: issue_tracker at your.tracker.email.domain.example
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+Subject: [issue1] Testing...
+Content-Type: text/plain;
+        charset="iso-8859-1"
+Content-Transfer-Encoding: quoted-printable
+
+A message with encoding (encoded oe =F6)
+
+''')
+        self.compareMessages(self._get_mail(),
+'''FROM: roundup-admin at your.tracker.email.domain.example
+TO: chef at bork.bork.bork, richard at test
+Content-Type: text/plain; charset=utf-8
+Subject: [issue1] Testing...
+To: chef at bork.bork.bork, richard at test
+From: "Contrary, Mary" <issue_tracker at your.tracker.email.domain.example>
+Reply-To: Roundup issue tracker <issue_tracker at your.tracker.email.domain.example>
+MIME-Version: 1.0
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+X-Roundup-Name: Roundup issue tracker
+X-Roundup-Loop: hello
+Content-Transfer-Encoding: quoted-printable
+
+
+Contrary, Mary <mary at test> added the comment:
+
+A message with encoding (encoded oe =C3=B6)
+
+----------
+status: unread -> chatting
+
+_______________________________________________________________________
+Roundup issue tracker <issue_tracker at your.tracker.email.domain.example>
+<http://tracker.example/cgi-bin/roundup.cgi/bugs/issue1>
+_______________________________________________________________________
+''')
+
+
+    def testMultipartEnc01(self):
+        self.doNewIssue()
+        self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: mary <mary at test>
+To: issue_tracker at your.tracker.email.domain.example
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+Subject: [issue1] Testing...
+Content-Type: multipart/mixed;
+        boundary="----_=_NextPart_000_01"
+
+This message is in MIME format. Since your mail reader does not understand
+this format, some or all of this message may not be legible.
+
+------_=_NextPart_000_01
+Content-Type: text/plain;
+        charset="iso-8859-1"
+Content-Transfer-Encoding: quoted-printable
+
+A message with first part encoded (encoded oe =F6)
+
+''')
+        self.compareMessages(self._get_mail(),
+'''FROM: roundup-admin at your.tracker.email.domain.example
+TO: chef at bork.bork.bork, richard at test
+Content-Type: text/plain; charset=utf-8
+Subject: [issue1] Testing...
+To: chef at bork.bork.bork, richard at test
+From: "Contrary, Mary" <issue_tracker at your.tracker.email.domain.example>
+Reply-To: Roundup issue tracker <issue_tracker at your.tracker.email.domain.example>
+MIME-Version: 1.0
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+X-Roundup-Name: Roundup issue tracker
+X-Roundup-Loop: hello
+Content-Transfer-Encoding: quoted-printable
+
+
+Contrary, Mary <mary at test> added the comment:
+
+A message with first part encoded (encoded oe =C3=B6)
+
+----------
+status: unread -> chatting
+
+_______________________________________________________________________
+Roundup issue tracker <issue_tracker at your.tracker.email.domain.example>
+<http://tracker.example/cgi-bin/roundup.cgi/bugs/issue1>
+_______________________________________________________________________
+''')
+
+    def testContentDisposition(self):
+        self.doNewIssue()
+        self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: mary <mary at test>
+To: issue_tracker at your.tracker.email.domain.example
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+Subject: [issue1] Testing...
+Content-Type: multipart/mixed; boundary="bCsyhTFzCvuiizWE"
+Content-Disposition: inline
+
+
+--bCsyhTFzCvuiizWE
+Content-Type: text/plain; charset=us-ascii
+Content-Disposition: inline
+
+test attachment binary
+
+--bCsyhTFzCvuiizWE
+Content-Type: application/octet-stream
+Content-Disposition: attachment; filename="main.dvi"
+
+xxxxxx
+
+--bCsyhTFzCvuiizWE--
+''')
+        messages = self.db.issue.get('1', 'messages')
+        messages.sort()
+        file = self.db.msg.get(messages[-1], 'files')[0]
+        self.assertEqual(self.db.file.get(file, 'name'), 'main.dvi')
+
+    def testFollowupStupidQuoting(self):
+        self.doNewIssue()
+
+        self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: richard <richard at test>
+To: issue_tracker at your.tracker.email.domain.example
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+Subject: Re: "[issue1] Testing... "
+
+This is a followup
+''')
+        self.compareMessages(self._get_mail(),
+'''FROM: roundup-admin at your.tracker.email.domain.example
+TO: chef at bork.bork.bork
+Content-Type: text/plain; charset=utf-8
+Subject: [issue1] Testing...
+To: chef at bork.bork.bork
+From: richard <issue_tracker at your.tracker.email.domain.example>
+Reply-To: Roundup issue tracker <issue_tracker at your.tracker.email.domain.example>
+MIME-Version: 1.0
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+X-Roundup-Name: Roundup issue tracker
+X-Roundup-Loop: hello
+Content-Transfer-Encoding: quoted-printable
+
+
+richard <richard at test> added the comment:
+
+This is a followup
+
+----------
+status: unread -> chatting
+
+_______________________________________________________________________
+Roundup issue tracker <issue_tracker at your.tracker.email.domain.example>
+<http://tracker.example/cgi-bin/roundup.cgi/bugs/issue1>
+_______________________________________________________________________
+''')
+
+    def testEmailQuoting(self):
+        self.instance.config.EMAIL_KEEP_QUOTED_TEXT = 'no'
+        self.innerTestQuoting('''This is a followup
+''')
+
+    def testEmailQuotingRemove(self):
+        self.instance.config.EMAIL_KEEP_QUOTED_TEXT = 'yes'
+        self.innerTestQuoting('''Blah blah wrote:
+> Blah bklaskdfj sdf asdf jlaskdf skj sdkfjl asdf
+>  skdjlkjsdfalsdkfjasdlfkj dlfksdfalksd fj
+>
+
+This is a followup
+''')
+
+    def innerTestQuoting(self, expect):
+        nodeid = self.doNewIssue()
+
+        messages = self.db.issue.get(nodeid, 'messages')
+
+        self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: richard <richard at test>
+To: issue_tracker at your.tracker.email.domain.example
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+Subject: Re: [issue1] Testing...
+
+Blah blah wrote:
+> Blah bklaskdfj sdf asdf jlaskdf skj sdkfjl asdf
+>  skdjlkjsdfalsdkfjasdlfkj dlfksdfalksd fj
+>
+
+This is a followup
+''')
+        # figure the new message id
+        newmessages = self.db.issue.get(nodeid, 'messages')
+        for msg in messages:
+            newmessages.remove(msg)
+        messageid = newmessages[0]
+
+        self.compareMessages(self.db.msg.get(messageid, 'content'), expect)
+
+    def testUserLookup(self):
+        i = self.db.user.create(username='user1', address='user1 at foo.com')
+        self.assertEqual(uidFromAddress(self.db, ('', 'user1 at foo.com'), 0), i)
+        self.assertEqual(uidFromAddress(self.db, ('', 'USER1 at foo.com'), 0), i)
+        i = self.db.user.create(username='user2', address='USER2 at foo.com')
+        self.assertEqual(uidFromAddress(self.db, ('', 'USER2 at foo.com'), 0), i)
+        self.assertEqual(uidFromAddress(self.db, ('', 'user2 at foo.com'), 0), i)
+
+    def testUserAlternateLookup(self):
+        i = self.db.user.create(username='user1', address='user1 at foo.com',
+                                alternate_addresses='user1 at bar.com')
+        self.assertEqual(uidFromAddress(self.db, ('', 'user1 at bar.com'), 0), i)
+        self.assertEqual(uidFromAddress(self.db, ('', 'USER1 at bar.com'), 0), i)
+
+    def testUserCreate(self):
+        i = uidFromAddress(self.db, ('', 'user at foo.com'), 1)
+        self.assertNotEqual(uidFromAddress(self.db, ('', 'user at bar.com'), 1), i)
+
+    def testRFC2822(self):
+        ascii_header = "[issue243] This is a \"test\" - with 'quotation' marks"
+        unicode_header = '[issue244] \xd0\xb0\xd0\xbd\xd0\xb4\xd1\x80\xd0\xb5\xd0\xb9'
+        unicode_encoded = '=?utf-8?q?[issue244]_=D0=B0=D0=BD=D0=B4=D1=80=D0=B5=D0=B9?='
+        self.assertEqual(rfc2822.encode_header(ascii_header), ascii_header)
+        self.assertEqual(rfc2822.encode_header(unicode_header), unicode_encoded)
+
+    def testRegistrationConfirmation(self):
+        otk = "Aj4euk4LZSAdwePohj90SME5SpopLETL"
+        self.db.getOTKManager().set(otk, username='johannes')
+        self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef at bork.bork.bork>
+To: issue_tracker at your.tracker.email.domain.example
+Cc: richard at test
+Message-Id: <dummy_test_message_id>
+Subject: Re: Complete your registration to Roundup issue tracker
+ -- key %s
+
+This is a test confirmation of registration.
+''' % otk)
+        self.db.user.lookup('johannes')
+
+    def testFollowupOnNonIssue(self):
+        self.db.keyword.create(name='Foo')
+        self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: richard <richard at test>
+To: issue_tracker at your.tracker.email.domain.example
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+Subject: [keyword1] Testing... [name=Bar]
+
+''')
+        self.assertEqual(self.db.keyword.get('1', 'name'), 'Bar')
+
+    def testResentFrom(self):
+        nodeid = self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef at bork.bork.bork>
+Resent-From: mary <mary at test>
+To: issue_tracker at your.tracker.email.domain.example
+Cc: richard at test
+Message-Id: <dummy_test_message_id>
+Subject: [issue] Testing...
+
+This is a test submission of a new issue.
+''')
+        assert not os.path.exists(SENDMAILDEBUG)
+        l = self.db.issue.get(nodeid, 'nosy')
+        l.sort()
+        self.assertEqual(l, [self.richard_id, self.mary_id])
+        return nodeid
+
+    def testDejaVu(self):
+        self.assertRaises(IgnoreLoop, self._handle_mail,
+            '''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef at bork.bork.bork>
+X-Roundup-Loop: hello
+To: issue_tracker at your.tracker.email.domain.example
+Cc: richard at test
+Message-Id: <dummy_test_message_id>
+Subject: Re: [issue] Testing...
+
+Hi, I've been mis-configured to loop messages back to myself.
+''')
+
+    def testItsBulkStupid(self):
+        self.assertRaises(IgnoreBulk, self._handle_mail,
+            '''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef at bork.bork.bork>
+Precedence: bulk
+To: issue_tracker at your.tracker.email.domain.example
+Cc: richard at test
+Message-Id: <dummy_test_message_id>
+Subject: Re: [issue] Testing...
+
+Hi, I'm on holidays, and this is a dumb auto-responder.
+''')
+
+    def testAutoReplyEmailsAreIgnored(self):
+        self.assertRaises(IgnoreBulk, self._handle_mail,
+            '''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef at bork.bork.bork>
+To: issue_tracker at your.tracker.email.domain.example
+Cc: richard at test
+Message-Id: <dummy_test_message_id>
+Subject: Re: [issue] Out of office AutoReply: Back next week
+
+Hi, I'm back in the office next week
+''')
+
+    def testNoSubject(self):
+        self.assertRaises(MailUsageError, self._handle_mail,
+            '''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef at bork.bork.bork>
+To: issue_tracker at your.tracker.email.domain.example
+Cc: richard at test
+Reply-To: chef at bork.bork.bork
+Message-Id: <dummy_test_message_id>
+
+''')
+
+    #
+    # TEST FOR INVALID DESIGNATOR HANDLING
+    #
+    def testInvalidDesignator(self):
+        self.assertRaises(MailUsageError, self._handle_mail,
+            '''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef at bork.bork.bork>
+To: issue_tracker at your.tracker.email.domain.example
+Subject: [frobulated] testing
+Cc: richard at test
+Reply-To: chef at bork.bork.bork
+Message-Id: <dummy_test_message_id>
+
+''')
+        self.assertRaises(MailUsageError, self._handle_mail,
+            '''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef at bork.bork.bork>
+To: issue_tracker at your.tracker.email.domain.example
+Subject: [issue12345] testing
+Cc: richard at test
+Reply-To: chef at bork.bork.bork
+Message-Id: <dummy_test_message_id>
+
+''')
+
+    def testInvalidClassLoose(self):
+        self.instance.config.MAILGW_SUBJECT_PREFIX_PARSING = 'loose'
+        nodeid = self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef at bork.bork.bork>
+To: issue_tracker at your.tracker.email.domain.example
+Subject: [frobulated] testing
+Cc: richard at test
+Reply-To: chef at bork.bork.bork
+Message-Id: <dummy_test_message_id>
+
+''')
+        assert not os.path.exists(SENDMAILDEBUG)
+        self.assertEqual(self.db.issue.get(nodeid, 'title'),
+            '[frobulated] testing')
+
+    def testInvalidClassLoose(self):
+        self.instance.config.MAILGW_SUBJECT_PREFIX_PARSING = 'loose'
+        nodeid = self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef at bork.bork.bork>
+To: issue_tracker at your.tracker.email.domain.example
+Subject: [issue1234] testing
+Cc: richard at test
+Reply-To: chef at bork.bork.bork
+Message-Id: <dummy_test_message_id>
+
+''')
+        assert not os.path.exists(SENDMAILDEBUG)
+        self.assertEqual(self.db.issue.get(nodeid, 'title'),
+            '[issue1234] testing')
+
+    def testClassLooseOK(self):
+        self.instance.config.MAILGW_SUBJECT_PREFIX_PARSING = 'loose'
+        self.db.keyword.create(name='Foo')
+        nodeid = self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef at bork.bork.bork>
+To: issue_tracker at your.tracker.email.domain.example
+Subject: [keyword1] Testing... [name=Bar]
+Cc: richard at test
+Reply-To: chef at bork.bork.bork
+Message-Id: <dummy_test_message_id>
+
+''')
+        assert not os.path.exists(SENDMAILDEBUG)
+        self.assertEqual(self.db.keyword.get('1', 'name'), 'Bar')
+
+    #
+    # TEST FOR INVALID COMMANDS HANDLING
+    #
+    def testInvalidCommands(self):
+        self.assertRaises(MailUsageError, self._handle_mail,
+            '''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef at bork.bork.bork>
+To: issue_tracker at your.tracker.email.domain.example
+Subject: testing [frobulated]
+Cc: richard at test
+Reply-To: chef at bork.bork.bork
+Message-Id: <dummy_test_message_id>
+
+''')
+
+    def testInvalidCommandPassthrough(self):
+        self.instance.config.MAILGW_SUBJECT_SUFFIX_PARSING = 'none'
+        nodeid = self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef at bork.bork.bork>
+To: issue_tracker at your.tracker.email.domain.example
+Subject: testing [frobulated]
+Cc: richard at test
+Reply-To: chef at bork.bork.bork
+Message-Id: <dummy_test_message_id>
+
+''')
+        assert not os.path.exists(SENDMAILDEBUG)
+        self.assertEqual(self.db.issue.get(nodeid, 'title'),
+            'testing [frobulated]')
+
+    def testInvalidCommandPassthroughLoose(self):
+        self.instance.config.MAILGW_SUBJECT_SUFFIX_PARSING = 'loose'
+        nodeid = self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef at bork.bork.bork>
+To: issue_tracker at your.tracker.email.domain.example
+Subject: testing [frobulated]
+Cc: richard at test
+Reply-To: chef at bork.bork.bork
+Message-Id: <dummy_test_message_id>
+
+''')
+        assert not os.path.exists(SENDMAILDEBUG)
+        self.assertEqual(self.db.issue.get(nodeid, 'title'),
+            'testing [frobulated]')
+
+    def testInvalidCommandPassthroughLooseOK(self):
+        self.instance.config.MAILGW_SUBJECT_SUFFIX_PARSING = 'loose'
+        nodeid = self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef at bork.bork.bork>
+To: issue_tracker at your.tracker.email.domain.example
+Subject: testing [assignedto=mary]
+Cc: richard at test
+Reply-To: chef at bork.bork.bork
+Message-Id: <dummy_test_message_id>
+
+''')
+        assert not os.path.exists(SENDMAILDEBUG)
+        self.assertEqual(self.db.issue.get(nodeid, 'title'), 'testing')
+        self.assertEqual(self.db.issue.get(nodeid, 'assignedto'), self.mary_id)
+
+    def testCommandDelimiters(self):
+        self.instance.config.MAILGW_SUBJECT_SUFFIX_DELIMITERS = '{}'
+        nodeid = self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef at bork.bork.bork>
+To: issue_tracker at your.tracker.email.domain.example
+Subject: testing {assignedto=mary}
+Cc: richard at test
+Reply-To: chef at bork.bork.bork
+Message-Id: <dummy_test_message_id>
+
+''')
+        assert not os.path.exists(SENDMAILDEBUG)
+        self.assertEqual(self.db.issue.get(nodeid, 'title'), 'testing')
+        self.assertEqual(self.db.issue.get(nodeid, 'assignedto'), self.mary_id)
+
+    def testCommandDelimitersIgnore(self):
+        self.instance.config.MAILGW_SUBJECT_SUFFIX_DELIMITERS = '{}'
+        nodeid = self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef at bork.bork.bork>
+To: issue_tracker at your.tracker.email.domain.example
+Subject: testing [assignedto=mary]
+Cc: richard at test
+Reply-To: chef at bork.bork.bork
+Message-Id: <dummy_test_message_id>
+
+''')
+        assert not os.path.exists(SENDMAILDEBUG)
+        self.assertEqual(self.db.issue.get(nodeid, 'title'),
+            'testing [assignedto=mary]')
+        self.assertEqual(self.db.issue.get(nodeid, 'assignedto'), None)
+
+def test_suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(MailgwTestCase))
+    return suite
+
+if __name__ == '__main__':
+    runner = unittest.TextTestRunner()
+    unittest.main(testRunner=runner)
+
+# vim: set filetype=python sts=4 sw=4 et si :

Added: tracker/vendor/roundup/current/test/test_mailsplit.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/test/test_mailsplit.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,239 @@
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+# 
+# $Id: test_mailsplit.py,v 1.15 2003/10/25 22:53:26 richard Exp $
+
+import unittest, cStringIO
+
+from roundup.mailgw import parseContent
+
+class MailsplitTestCase(unittest.TestCase):
+    def testPreComment(self):
+        s = '''
+blah blah blah blah... blah blah? blah blah blah blah blah. blah blah blah
+blah blah blah blah blah blah blah blah blah blah blah!
+
+issue_tracker at foo.com wrote:
+> blah blah blah blahblah blahblah blahblah blah blah blah blah blah blah
+> blah blah blah blah blah blah blah blah blah?  blah blah blah blah blah
+> blah blah blah blah blah blah blah...  blah blah blah blah.  blah blah
+> blah blah blah blah?  blah blah blah blah blah blah!  blah blah!
+>
+> -------
+> nosy: userfoo, userken
+> _________________________________________________
+> Roundup issue tracker
+> issue_tracker at foo.com
+> http://foo.com/cgi-bin/roundup.cgi/issue_tracker/
+
+--
+blah blah blah signature
+userfoo at foo.com
+'''
+        summary, content = parseContent(s, 0, 0)
+        self.assertEqual(summary, 'blah blah blah blah... blah blah? blah blah blah blah blah. blah blah blah')
+        self.assertEqual(content, 'blah blah blah blah... blah blah? blah blah blah blah blah. blah blah blah\nblah blah blah blah blah blah blah blah blah blah blah!')
+
+
+    def testPostComment(self):
+        s = '''
+issue_tracker at foo.com wrote:
+> blah blah blah blahblah blahblah blahblah blah blah blah blah blah
+> blah
+> blah blah blah blah blah blah blah blah blah?  blah blah blah blah
+> blah
+> blah blah blah blah blah blah blah...  blah blah blah blah.  blah
+> blah
+> blah blah blah blah?  blah blah blah blah blah blah!  blah blah!
+>
+> -------
+> nosy: userfoo, userken
+> _________________________________________________
+> Roundup issue tracker
+> issue_tracker at foo.com
+> http://foo.com/cgi-bin/roundup.cgi/issue_tracker/
+
+blah blah blah blah... blah blah? blah blah blah blah blah. blah blah blah
+blah blah blah blah blah blah blah blah blah blah blah!
+
+--
+blah blah blah signature
+userfoo at foo.com
+'''
+        summary, content = parseContent(s, 0, 0)
+        self.assertEqual(summary, 'blah blah blah blah... blah blah? blah blah blah blah blah. blah blah blah')
+        self.assertEqual(content, 'blah blah blah blah... blah blah? blah blah blah blah blah. blah blah blah\nblah blah blah blah blah blah blah blah blah blah blah!')
+
+
+    def testKeepCitation(self):
+        s = '''
+blah blah blah blah... blah blah? blah blah blah blah blah. blah blah blah
+blah blah blah blah blah blah blah blah blah blah blah!
+
+issue_tracker at foo.com wrote:
+> blah blah blah blahblah blahblah blahblah blah blah blah blah blah blah
+> blah blah blah blah blah blah blah blah blah?  blah blah blah blah blah
+> blah blah blah blah blah blah blah...  blah blah blah blah.  blah blah
+> blah blah blah blah?  blah blah blah blah blah blah!  blah blah!
+>
+> -------
+> nosy: userfoo, userken
+> _________________________________________________
+> Roundup issue tracker
+> issue_tracker at foo.com
+> http://foo.com/cgi-bin/roundup.cgi/issue_tracker/
+
+--
+blah blah blah signature
+userfoo at foo.com
+'''
+        summary, content = parseContent(s, 1, 0)
+        self.assertEqual(summary, 'blah blah blah blah... blah blah? blah blah blah blah blah. blah blah blah')
+        self.assertEqual(content, '''\
+blah blah blah blah... blah blah? blah blah blah blah blah. blah blah blah
+blah blah blah blah blah blah blah blah blah blah blah!
+
+issue_tracker at foo.com wrote:
+> blah blah blah blahblah blahblah blahblah blah blah blah blah blah blah
+> blah blah blah blah blah blah blah blah blah?  blah blah blah blah blah
+> blah blah blah blah blah blah blah...  blah blah blah blah.  blah blah
+> blah blah blah blah?  blah blah blah blah blah blah!  blah blah!
+>
+> -------
+> nosy: userfoo, userken
+> _________________________________________________
+> Roundup issue tracker
+> issue_tracker at foo.com
+> http://foo.com/cgi-bin/roundup.cgi/issue_tracker/''')
+
+
+    def testKeepBody(self):
+        s = '''
+blah blah blah blah... blah blah? blah blah blah blah blah. blah blah blah
+blah blah blah blah blah blah blah blah blah blah blah!
+
+issue_tracker at foo.com wrote:
+> blah blah blah blahblah blahblah blahblah blah blah blah blah blah blah
+> blah blah blah blah blah blah blah blah blah?  blah blah blah blah blah
+> blah blah blah blah blah blah blah...  blah blah blah blah.  blah blah
+> blah blah blah blah?  blah blah blah blah blah blah!  blah blah!
+>
+> -------
+> nosy: userfoo, userken
+> _________________________________________________
+> Roundup issue tracker
+> issue_tracker at foo.com
+> http://foo.com/cgi-bin/roundup.cgi/issue_tracker/
+
+--
+blah blah blah signature
+userfoo at foo.com
+'''
+        summary, content = parseContent(s, 0, 1)
+        self.assertEqual(summary, 'blah blah blah blah... blah blah? blah blah blah blah blah. blah blah blah')
+        self.assertEqual(content, '''
+blah blah blah blah... blah blah? blah blah blah blah blah. blah blah blah
+blah blah blah blah blah blah blah blah blah blah blah!
+
+issue_tracker at foo.com wrote:
+> blah blah blah blahblah blahblah blahblah blah blah blah blah blah blah
+> blah blah blah blah blah blah blah blah blah?  blah blah blah blah blah
+> blah blah blah blah blah blah blah...  blah blah blah blah.  blah blah
+> blah blah blah blah?  blah blah blah blah blah blah!  blah blah!
+>
+> -------
+> nosy: userfoo, userken
+> _________________________________________________
+> Roundup issue tracker
+> issue_tracker at foo.com
+> http://foo.com/cgi-bin/roundup.cgi/issue_tracker/
+
+--
+blah blah blah signature
+userfoo at foo.com
+''')
+
+    def testAllQuoted(self):
+        s = '\nissue_tracker at foo.com wrote:\n> testing\n'
+        summary, content = parseContent(s, 0, 1)
+        self.assertEqual(summary, '')
+        self.assertEqual(content, s)
+
+    def testSimple(self):
+        s = '''testing'''
+        summary, content = parseContent(s, 0, 0)
+        self.assertEqual(summary, 'testing')
+        self.assertEqual(content, 'testing')
+
+    def testParagraphs(self):
+        s = '''testing\n\ntesting\n\ntesting'''
+        summary, content = parseContent(s, 0, 0)
+        self.assertEqual(summary, 'testing')
+        self.assertEqual(content, 'testing\n\ntesting\n\ntesting')
+
+    def testSimpleFollowup(self):
+        s = '''>hello\ntesting'''
+        summary, content = parseContent(s, 0, 0)
+        self.assertEqual(summary, 'testing')
+        self.assertEqual(content, 'testing')
+
+    def testSimpleFollowupParas(self):
+        s = '''>hello\ntesting\n\ntesting\n\ntesting'''
+        summary, content = parseContent(s, 0, 0)
+        self.assertEqual(summary, 'testing')
+        self.assertEqual(content, 'testing\n\ntesting\n\ntesting')
+
+    def testEmpty(self):
+        s = ''
+        summary, content = parseContent(s, 0, 0)
+        self.assertEqual(summary, '')
+        self.assertEqual(content, '')
+
+    def testIndentationSummary(self):
+        s = '    Four space indent.\n\n    Four space indent.\nNo indent.'
+        summary, content = parseContent(s, 0, 0)
+        self.assertEqual(summary, '    Four space indent.')
+
+    def testIndentationContent(self):
+        s = '    Four space indent.\n\n    Four space indent.\nNo indent.'
+        summary, content = parseContent(s, 0, 0)
+        self.assertEqual(content, s)
+
+    def testMultilineSummary(self):
+        s = 'This is a long sentence that would normally\nbe split. More words.'
+        summary, content = parseContent(s, 0, 0)
+        self.assertEqual(summary, 'This is a long sentence that would '
+            'normally\nbe split.')
+
+    def testKeepMultipleHyphens(self):
+        body = '''Testing, testing.
+
+----
+Testing, testing.'''
+        summary, content = parseContent(body, 1, 0)
+        self.assertEqual(body, content)
+
+def test_suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(MailsplitTestCase))
+    return suite
+
+if __name__ == '__main__':
+    runner = unittest.TextTestRunner()
+    unittest.main(testRunner=runner)
+
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/test/test_metakit.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/test/test_metakit.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,83 @@
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+#
+# $Id: test_metakit.py,v 1.7 2004/11/18 16:33:43 a1s Exp $
+import unittest, os, shutil, time, weakref
+
+from db_test_base import DBTest, ROTest, SchemaTest, ClassicInitTest, config, password
+
+from roundup.backends import get_backend, have_backend
+
+class metakitOpener:
+    if have_backend('metakit'):
+        module = get_backend('metakit')
+        module._instances = weakref.WeakValueDictionary()
+
+    def nuke_database(self):
+        shutil.rmtree(config.DATABASE)
+
+class metakitDBTest(metakitOpener, DBTest):
+    def testBooleanUnset(self):
+        # XXX: metakit can't unset Booleans :(
+        nid = self.db.user.create(username='foo', assignable=1)
+        self.db.user.set(nid, assignable=None)
+        self.assertEqual(self.db.user.get(nid, "assignable"), 0)
+
+    def testNumberUnset(self):
+        # XXX: metakit can't unset Numbers :(
+        nid = self.db.user.create(username='foo', age=1)
+        self.db.user.set(nid, age=None)
+        self.assertEqual(self.db.user.get(nid, "age"), 0)
+
+    def testPasswordUnset(self):
+        # XXX: metakit can't unset Numbers (id's) :(
+        x = password.Password('x')
+        nid = self.db.user.create(username='foo', password=x)
+        self.db.user.set(nid, assignable=None)
+        self.assertEqual(self.db.user.get(nid, "assignable"), 0)
+
+class metakitROTest(metakitOpener, ROTest):
+    pass
+
+class metakitSchemaTest(metakitOpener, SchemaTest):
+    pass
+
+class metakitClassicInitTest(ClassicInitTest):
+    backend = 'metakit'
+
+from session_common import DBMTest
+class metakitSessionTest(metakitOpener, DBMTest):
+    pass
+
+def test_suite():
+    suite = unittest.TestSuite()
+    if not have_backend('metakit'):
+        print 'Skipping metakit tests'
+        return suite
+    print 'Including metakit tests'
+    suite.addTest(unittest.makeSuite(metakitDBTest))
+    suite.addTest(unittest.makeSuite(metakitROTest))
+    suite.addTest(unittest.makeSuite(metakitSchemaTest))
+    suite.addTest(unittest.makeSuite(metakitClassicInitTest))
+    suite.addTest(unittest.makeSuite(metakitSessionTest))
+    return suite
+
+if __name__ == '__main__':
+    runner = unittest.TextTestRunner()
+    unittest.main(testRunner=runner)
+
+# vim: set et sts=4 sw=4 :

Added: tracker/vendor/roundup/current/test/test_multipart.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/test/test_multipart.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,230 @@
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+# 
+# $Id: test_multipart.py,v 1.7 2004/01/17 13:49:06 jlgijsbers Exp $ 
+
+import unittest
+from cStringIO import StringIO
+
+from roundup.mailgw import Message
+
+class TestMessage(Message):
+    table = {'multipart/signed': '    boundary="boundary-%(indent)s";\n',
+             'multipart/mixed': '    boundary="boundary-%(indent)s";\n',
+             'multipart/alternative': '    boundary="boundary-%(indent)s";\n',
+             'text/plain': '    name="foo.txt"\nfoo\n',
+             'application/pgp-signature': '    name="foo.gpg"\nfoo\n',
+             'application/pdf': '    name="foo.pdf"\nfoo\n',
+             'message/rfc822': 'Subject: foo\n\nfoo\n'}
+    
+    def __init__(self, spec):
+        """Create a basic MIME message according to 'spec'.
+
+        Each line of a spec has one content-type, which is optionally indented.
+        The indentation signifies how deep in the MIME hierarchy the
+        content-type is.
+
+        """
+        parts = []
+        for line in spec.splitlines():
+            content_type = line.strip()
+            if not content_type:
+                continue
+            
+            indent = self.getIndent(line)
+            if indent:
+                parts.append('--boundary-%s\n' % indent)
+            parts.append('Content-type: %s;\n' % content_type)
+            parts.append(self.table[content_type] % {'indent': indent + 1})
+
+        Message.__init__(self, StringIO(''.join(parts)))
+
+    def getIndent(self, line):
+        """Get the current line's indentation, using four-space indents."""
+        count = 0
+        for char in line:
+            if char != ' ':
+                break
+            count += 1
+        return count / 4
+
+class MultipartTestCase(unittest.TestCase):
+    def setUp(self):
+        self.fp = StringIO()
+        w = self.fp.write
+        w('Content-Type: multipart/mixed; boundary="foo"\r\n\r\n')
+        w('This is a multipart message. Ignore this bit.\r\n')
+        w('--foo\r\n')
+
+        w('Content-Type: text/plain\r\n\r\n')
+        w('Hello, world!\r\n')
+        w('\r\n')
+        w('Blah blah\r\n')
+        w('foo\r\n')
+        w('-foo\r\n')
+        w('--foo\r\n')
+
+        w('Content-Type: multipart/alternative; boundary="bar"\r\n\r\n')
+        w('This is a multipart message. Ignore this bit.\r\n')
+        w('--bar\r\n')
+
+        w('Content-Type: text/plain\r\n\r\n')
+        w('Hello, world!\r\n')
+        w('\r\n')
+        w('Blah blah\r\n')
+        w('--bar\r\n')
+
+        w('Content-Type: text/html\r\n\r\n')
+        w('<b>Hello, world!</b>\r\n')
+        w('--bar--\r\n')
+        w('--foo\r\n')
+
+        w('Content-Type: text/plain\r\n\r\n')
+        w('Last bit\n')
+        w('--foo--\r\n')
+        self.fp.seek(0)
+
+    def testMultipart(self):
+        m = Message(self.fp)
+        self.assert_(m is not None)
+
+        # skip the first bit
+        p = m.getpart()
+        self.assert_(p is not None)
+        self.assertEqual(p.fp.read(),
+            'This is a multipart message. Ignore this bit.\r\n')
+
+        # first text/plain
+        p = m.getpart()
+        self.assert_(p is not None)
+        self.assertEqual(p.gettype(), 'text/plain')
+        self.assertEqual(p.fp.read(),
+            'Hello, world!\r\n\r\nBlah blah\r\nfoo\r\n-foo\r\n')
+
+        # sub-multipart
+        p = m.getpart()
+        self.assert_(p is not None)
+        self.assertEqual(p.gettype(), 'multipart/alternative')
+
+        # sub-multipart text/plain
+        q = p.getpart()
+        self.assert_(q is not None)
+        q = p.getpart()
+        self.assert_(q is not None)
+        self.assertEqual(q.gettype(), 'text/plain')
+        self.assertEqual(q.fp.read(), 'Hello, world!\r\n\r\nBlah blah\r\n')
+
+        # sub-multipart text/html
+        q = p.getpart()
+        self.assert_(q is not None)
+        self.assertEqual(q.gettype(), 'text/html')
+        self.assertEqual(q.fp.read(), '<b>Hello, world!</b>\r\n')
+
+        # sub-multipart end
+        q = p.getpart()
+        self.assert_(q is None)
+
+        # final text/plain
+        p = m.getpart()
+        self.assert_(p is not None)
+        self.assertEqual(p.gettype(), 'text/plain')
+        self.assertEqual(p.fp.read(),
+            'Last bit\n')
+
+        # end
+        p = m.getpart()
+        self.assert_(p is None)
+
+    def TestExtraction(self, spec, expected):
+        self.assertEqual(TestMessage(spec).extract_content(), expected)
+
+    def testTextPlain(self):
+        self.TestExtraction('text/plain', ('foo\n', []))
+
+    def testAttachedTextPlain(self):
+        self.TestExtraction("""
+multipart/mixed
+    text/plain
+    text/plain""",
+                  ('foo\n',
+                   [('foo.txt', 'text/plain', 'foo\n')]))
+
+    def testMultipartMixed(self):
+        self.TestExtraction("""
+multipart/mixed
+    text/plain
+    application/pdf""",
+                  ('foo\n',
+                   [('foo.pdf', 'application/pdf', 'foo\n')]))
+
+    def testMultipartAlternative(self):
+        self.TestExtraction("""
+multipart/alternative
+    text/plain
+    application/pdf
+""", ('foo\n', [('foo.pdf', 'application/pdf', 'foo\n')]))
+
+    def testDeepMultipartAlternative(self):
+        self.TestExtraction("""
+multipart/mixed
+    multipart/alternative
+        text/plain
+        application/pdf
+""", ('foo\n', [('foo.pdf', 'application/pdf', 'foo\n')]))
+    
+    def testSignedText(self):
+        self.TestExtraction("""
+multipart/signed
+    text/plain
+    application/pgp-signature""", ('foo\n', []))
+
+    def testSignedAttachments(self):
+        self.TestExtraction("""
+multipart/signed
+    multipart/mixed
+        text/plain
+        application/pdf
+    application/pgp-signature""",
+                  ('foo\n',
+                   [('foo.pdf', 'application/pdf', 'foo\n')]))
+
+    def testAttachedSignature(self):
+        self.TestExtraction("""
+multipart/mixed
+    text/plain
+    application/pgp-signature""",
+                  ('foo\n',
+                   [('foo.gpg', 'application/pgp-signature', 'foo\n')]))
+
+    def testMessageRfc822(self):
+        self.TestExtraction("""
+multipart/mixed
+    message/rfc822""",
+                  (None,
+                   [('foo', 'message/rfc822', 'foo\n')]))
+
+def test_suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(MultipartTestCase))
+    return suite
+
+if __name__ == '__main__':
+    runner = unittest.TextTestRunner()
+    unittest.main(testRunner=runner)
+
+
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/test/test_mysql.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/test/test_mysql.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,101 @@
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+#
+# $Id: test_mysql.py,v 1.15 2004/11/10 22:22:59 richard Exp $
+
+import unittest, os, shutil, time, imp
+
+from roundup.hyperdb import DatabaseError
+from roundup.backends import get_backend, have_backend
+
+from db_test_base import DBTest, ROTest, config, SchemaTest, ClassicInitTest
+
+
+class mysqlOpener:
+    if have_backend('mysql'):
+        module = get_backend('mysql')
+
+    def setUp(self):
+        self.module.db_nuke(config)
+
+    def tearDown(self):
+        self.db.close()
+        self.nuke_database()
+
+    def nuke_database(self):
+        self.module.db_nuke(config)
+
+class mysqlDBTest(mysqlOpener, DBTest):
+    def setUp(self):
+        mysqlOpener.setUp(self)
+        DBTest.setUp(self)
+
+class mysqlROTest(mysqlOpener, ROTest):
+    def setUp(self):
+        mysqlOpener.setUp(self)
+        ROTest.setUp(self)
+
+class mysqlSchemaTest(mysqlOpener, SchemaTest):
+    def setUp(self):
+        mysqlOpener.setUp(self)
+        SchemaTest.setUp(self)
+
+class mysqlClassicInitTest(mysqlOpener, ClassicInitTest):
+    backend = 'mysql'
+    def setUp(self):
+        mysqlOpener.setUp(self)
+        ClassicInitTest.setUp(self)
+    def tearDown(self):
+        ClassicInitTest.tearDown(self)
+        self.nuke_database()
+
+from session_common import RDBMSTest
+class mysqlSessionTest(mysqlOpener, RDBMSTest):
+    def setUp(self):
+        mysqlOpener.setUp(self)
+        RDBMSTest.setUp(self)
+    def tearDown(self):
+        RDBMSTest.tearDown(self)
+        mysqlOpener.tearDown(self)
+
+def test_suite():
+    suite = unittest.TestSuite()
+    if not have_backend('mysql'):
+        print "Skipping mysql tests"
+        return suite
+
+    import MySQLdb
+    try:
+        # Check if we can connect to the server.
+        # use db_exists() to make a connection, ignore it's return value
+        mysqlOpener.module.db_exists(config)
+    except (MySQLdb.MySQLError, DatabaseError), msg:
+        print "Skipping mysql tests (%s)"%msg
+    else:
+        print 'Including mysql tests'
+        suite.addTest(unittest.makeSuite(mysqlDBTest))
+        suite.addTest(unittest.makeSuite(mysqlROTest))
+        suite.addTest(unittest.makeSuite(mysqlSchemaTest))
+        suite.addTest(unittest.makeSuite(mysqlClassicInitTest))
+        suite.addTest(unittest.makeSuite(mysqlSessionTest))
+    return suite
+
+if __name__ == '__main__':
+    runner = unittest.TextTestRunner()
+    unittest.main(testRunner=runner)
+
+# vim: set et sts=4 sw=4 :

Added: tracker/vendor/roundup/current/test/test_postgresql.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/test/test_postgresql.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,116 @@
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+#
+# $Id: test_postgresql.py,v 1.12 2004/11/03 01:34:21 richard Exp $
+
+import unittest
+
+from roundup.hyperdb import DatabaseError
+
+from db_test_base import DBTest, ROTest, config, SchemaTest, ClassicInitTest
+
+from roundup.backends import get_backend, have_backend
+
+class postgresqlOpener:
+    if have_backend('postgresql'):
+        module = get_backend('postgresql')
+
+    def setUp(self):
+        pass
+
+    def tearDown(self):
+        self.nuke_database()
+
+    def nuke_database(self):
+        # clear out the database - easiest way is to nuke and re-create it
+        self.module.db_nuke(config)
+
+class postgresqlDBTest(postgresqlOpener, DBTest):
+    def setUp(self):
+        postgresqlOpener.setUp(self)
+        DBTest.setUp(self)
+
+    def tearDown(self):
+        DBTest.tearDown(self)
+        postgresqlOpener.tearDown(self)
+
+    def testFilteringIntervalSort(self):
+        # PostgreSQL sorts NULLs differently to other databases (others
+        # treat it as lower than real values, PG treats it as higher)
+        ae, filt = self.filteringSetup()
+        # ascending should sort None, 1:10, 1d
+        ae(filt(None, {}, ('+','foo'), (None,None)), ['4', '1', '2', '3'])
+        # descending should sort 1d, 1:10, None
+        ae(filt(None, {}, ('-','foo'), (None,None)), ['3', '2', '1', '4'])
+
+class postgresqlROTest(postgresqlOpener, ROTest):
+    def setUp(self):
+        postgresqlOpener.setUp(self)
+        ROTest.setUp(self)
+
+    def tearDown(self):
+        ROTest.tearDown(self)
+        postgresqlOpener.tearDown(self)
+
+class postgresqlSchemaTest(postgresqlOpener, SchemaTest):
+    def setUp(self):
+        postgresqlOpener.setUp(self)
+        SchemaTest.setUp(self)
+
+    def tearDown(self):
+        SchemaTest.tearDown(self)
+        postgresqlOpener.tearDown(self)
+
+class postgresqlClassicInitTest(postgresqlOpener, ClassicInitTest):
+    backend = 'postgresql'
+    def setUp(self):
+        postgresqlOpener.setUp(self)
+        ClassicInitTest.setUp(self)
+
+    def tearDown(self):
+        ClassicInitTest.tearDown(self)
+        postgresqlOpener.tearDown(self)
+
+from session_common import RDBMSTest
+class postgresqlSessionTest(postgresqlOpener, RDBMSTest):
+    def setUp(self):
+        postgresqlOpener.setUp(self)
+        RDBMSTest.setUp(self)
+    def tearDown(self):
+        RDBMSTest.tearDown(self)
+        postgresqlOpener.tearDown(self)
+
+def test_suite():
+    suite = unittest.TestSuite()
+    if not have_backend('postgresql'):
+        print "Skipping postgresql tests"
+        return suite
+
+    # make sure we start with a clean slate
+    if postgresqlOpener.module.db_exists(config):
+        postgresqlOpener.module.db_nuke(config, 1)
+
+    # TODO: Check if we can run postgresql tests
+    print 'Including postgresql tests'
+    suite.addTest(unittest.makeSuite(postgresqlDBTest))
+    suite.addTest(unittest.makeSuite(postgresqlROTest))
+    suite.addTest(unittest.makeSuite(postgresqlSchemaTest))
+    suite.addTest(unittest.makeSuite(postgresqlClassicInitTest))
+    suite.addTest(unittest.makeSuite(postgresqlSessionTest))
+    return suite
+
+# vim: set et sts=4 sw=4 :

Added: tracker/vendor/roundup/current/test/test_rfc2822.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/test/test_rfc2822.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,27 @@
+from roundup.rfc2822 import decode_header, encode_header
+
+import unittest, time
+ 
+class RFC2822TestCase(unittest.TestCase):
+    def testDecode(self):
+        src = 'Re: [it_issue3] '\
+            '=?ISO-8859-1?Q?Ren=E9s_[resp=3Dg=2Cstatus=3D?= '\
+            '=?ISO-8859-1?Q?feedback]?='
+        result = 'Re: [it_issue3] Ren\xc3\xa9s [resp=g,status=feedback]'
+        self.assertEqual(decode_header(src), result)
+
+        src = 'Re: [it_issue3]'\
+            ' =?ISO-8859-1?Q?Ren=E9s_[resp=3Dg=2Cstatus=3D?=' \
+            ' =?ISO-8859-1?Q?feedback]?='
+        result = 'Re: [it_issue3] Ren\xc3\xa9s [resp=g,status=feedback]'
+        self.assertEqual(decode_header(src), result)
+
+    def testEncode(self):
+        src = 'Re: [it_issue3] Ren\xc3\xa9s [status=feedback]'
+        result = '=?utf-8?q?Re:_[it=5Fissue3]_Ren=C3=A9s_[status=3Dfeedback]?='
+        self.assertEqual(encode_header(src), result)
+
+def test_suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(RFC2822TestCase))
+    return suite

Added: tracker/vendor/roundup/current/test/test_schema.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/test/test_schema.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,88 @@
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+#
+# $Id: test_schema.py,v 1.15 2004/10/16 12:43:11 a1s Exp $
+
+import unittest, os, shutil
+
+from roundup import configuration
+from roundup.backends import back_anydbm
+from roundup.hyperdb import String, Password, Link, Multilink, Date, \
+    Interval
+
+config = configuration.CoreConfig()
+config.DATABASE = "_test_dir"
+
+class SchemaTestCase(unittest.TestCase):
+    def setUp(self):
+        # remove previous test, ignore errors
+        if os.path.exists(config.DATABASE):
+            shutil.rmtree(config.DATABASE)
+        os.makedirs(config.DATABASE + '/files')
+        self.db = back_anydbm.Database(config, 'admin')
+        self.db.post_init()
+        self.db.clear()
+
+    def tearDown(self):
+        self.db.close()
+        shutil.rmtree(config.DATABASE)
+
+    def testA_Status(self):
+        status = back_anydbm.Class(self.db, "status", name=String())
+        self.assert_(status, 'no class object generated')
+        status.setkey("name")
+        val = status.create(name="unread")
+        self.assertEqual(val, '1', 'expecting "1"')
+        val = status.create(name="in-progress")
+        self.assertEqual(val, '2', 'expecting "2"')
+        val = status.create(name="testing")
+        self.assertEqual(val, '3', 'expecting "3"')
+        val = status.create(name="resolved")
+        self.assertEqual(val, '4', 'expecting "4"')
+        val = status.count()
+        self.assertEqual(val, 4, 'expecting 4')
+        val = status.list()
+        self.assertEqual(val, ['1', '2', '3', '4'], 'blah')
+        val = status.lookup("in-progress")
+        self.assertEqual(val, '2', 'expecting "2"')
+        status.retire('3')
+        val = status.list()
+        self.assertEqual(val, ['1', '2', '4'], 'blah')
+
+    def testB_Issue(self):
+        issue = back_anydbm.Class(self.db, "issue", title=String(),
+            status=Link("status"))
+        self.assert_(issue, 'no class object returned')
+
+    def testC_User(self):
+        user = back_anydbm.Class(self.db, "user", username=String(),
+            password=Password())
+        self.assert_(user, 'no class object returned')
+        user.setkey("username")
+
+
+def test_suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(SchemaTestCase))
+    return suite
+
+if __name__ == '__main__':
+    runner = unittest.TextTestRunner()
+    unittest.main(testRunner=runner)
+
+
+# vim: set filetype=python sts=4 sw=4 et si :

Added: tracker/vendor/roundup/current/test/test_security.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/test/test_security.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,190 @@
+# Copyright (c) 2002 ekit.com Inc (http://www.ekit-inc.com/)
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+#   The above copyright notice and this permission notice shall be included in
+#   all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+# $Id: test_security.py,v 1.10 2006/02/03 04:04:37 richard Exp $
+
+import os, unittest, shutil
+
+from roundup import backends
+from roundup.password import Password
+from db_test_base import setupSchema, MyTestCase, config
+
+class PermissionTest(MyTestCase):
+    def setUp(self):
+        backend = backends.get_backend('anydbm')
+        # remove previous test, ignore errors
+        if os.path.exists(config.DATABASE):
+            shutil.rmtree(config.DATABASE)
+        os.makedirs(config.DATABASE + '/files')
+        self.db = backend.Database(config, 'admin')
+        setupSchema(self.db, 1, backend)
+
+    def testInterfaceSecurity(self):
+        ' test that the CGI and mailgw have initialised security OK '
+        # TODO: some asserts
+
+    def testInitialiseSecurity(self):
+        ei = self.db.security.addPermission(name="Edit", klass="issue",
+                        description="User is allowed to edit issues")
+        self.db.security.addPermissionToRole('User', ei)
+        ai = self.db.security.addPermission(name="View", klass="issue",
+                        description="User is allowed to access issues")
+        self.db.security.addPermissionToRole('User', ai)
+
+    def testAdmin(self):
+        ei = self.db.security.addPermission(name="Edit", klass="issue",
+                        description="User is allowed to edit issues")
+        self.db.security.addPermissionToRole('User', ei)
+        ei = self.db.security.addPermission(name="Edit", klass=None,
+                        description="User is allowed to edit issues")
+        self.db.security.addPermissionToRole('Admin', ei)
+
+        u1 = self.db.user.create(username='one', roles='Admin')
+        u2 = self.db.user.create(username='two', roles='User')
+
+        self.assert_(self.db.security.hasPermission('Edit', u1, None))
+        self.assert_(not self.db.security.hasPermission('Edit', u2, None))
+
+
+    def testGetPermission(self):
+        self.db.security.getPermission('Edit')
+        self.db.security.getPermission('View')
+        self.assertRaises(ValueError, self.db.security.getPermission, 'x')
+        self.assertRaises(ValueError, self.db.security.getPermission, 'Edit',
+            'fubar')
+
+        add = self.db.security.addPermission
+        get = self.db.security.getPermission
+
+        # class
+        ei = add(name="Edit", klass="issue")
+        self.assertEquals(get('Edit', 'issue'), ei)
+        ai = add(name="View", klass="issue")
+        self.assertEquals(get('View', 'issue'), ai)
+
+        # property
+        epi = add(name="Edit", klass="issue", properties=['title'])
+        self.assertEquals(get('Edit', 'issue', properties=['title']), epi)
+        api = add(name="View", klass="issue", properties=['title'])
+        self.assertEquals(get('View', 'issue', properties=['title']), api)
+        
+        # check function
+        dummy = lambda: 0
+        eci = add(name="Edit", klass="issue", check=dummy)
+        self.assertEquals(get('Edit', 'issue', check=dummy), eci)
+        aci = add(name="View", klass="issue", check=dummy)
+        self.assertEquals(get('View', 'issue', check=dummy), aci)
+
+        # all
+        epci = add(name="Edit", klass="issue", properties=['title'],
+            check=dummy)
+        self.assertEquals(get('Edit', 'issue', properties=['title'],
+            check=dummy), epci)
+        apci = add(name="View", klass="issue", properties=['title'],
+            check=dummy)
+        self.assertEquals(get('View', 'issue', properties=['title'],
+            check=dummy), apci)
+
+    def testDBinit(self):
+        self.db.user.create(username="demo", roles='User')
+        self.db.user.create(username="anonymous", roles='Anonymous')
+
+    def testAccessControls(self):
+        add = self.db.security.addPermission
+        has = self.db.security.hasPermission
+        addRole = self.db.security.addRole
+        addToRole = self.db.security.addPermissionToRole
+
+        none = self.db.user.create(username='none', roles='None')
+
+        # test admin access
+        addRole(name='Super')
+        addToRole('Super', add(name="Test"))
+        super = self.db.user.create(username='super', roles='Super')
+
+        # test class-level access
+        addRole(name='Role1')
+        addToRole('Role1', add(name="Test", klass="test"))
+        user1 = self.db.user.create(username='user1', roles='Role1')
+        self.assertEquals(has('Test', user1, 'test'), 1)
+        self.assertEquals(has('Test', super, 'test'), 1)
+        self.assertEquals(has('Test', none, 'test'), 0)
+
+        # property
+        addRole(name='Role2')
+        addToRole('Role2', add(name="Test", klass="test", properties=['a','b']))
+        user2 = self.db.user.create(username='user2', roles='Role2')
+        # *any* access to class
+        self.assertEquals(has('Test', user1, 'test'), 1)
+        self.assertEquals(has('Test', user2, 'test'), 1)
+
+        # *any* access to item
+        self.assertEquals(has('Test', user1, 'test', itemid='1'), 1)
+        self.assertEquals(has('Test', user2, 'test', itemid='1'), 1)
+        self.assertEquals(has('Test', super, 'test', itemid='1'), 1)
+        self.assertEquals(has('Test', none, 'test', itemid='1'), 0)
+
+        # now property test
+        self.assertEquals(has('Test', user2, 'test', property='a'), 1)
+        self.assertEquals(has('Test', user2, 'test', property='b'), 1)
+        self.assertEquals(has('Test', user2, 'test', property='c'), 0)
+        self.assertEquals(has('Test', user1, 'test', property='a'), 1)
+        self.assertEquals(has('Test', user1, 'test', property='b'), 1)
+        self.assertEquals(has('Test', user1, 'test', property='c'), 1)
+        self.assertEquals(has('Test', super, 'test', property='a'), 1)
+        self.assertEquals(has('Test', super, 'test', property='b'), 1)
+        self.assertEquals(has('Test', super, 'test', property='c'), 1)
+        self.assertEquals(has('Test', none, 'test', property='a'), 0)
+        self.assertEquals(has('Test', none, 'test', property='b'), 0)
+        self.assertEquals(has('Test', none, 'test', property='c'), 0)
+        self.assertEquals(has('Test', none, 'test'), 0)
+
+        # check function
+        check = lambda db, userid, itemid: itemid == '1'
+        addRole(name='Role3')
+        addToRole('Role3', add(name="Test", klass="test", check=check))
+        user3 = self.db.user.create(username='user3', roles='Role3')
+        # *any* access to class
+        self.assertEquals(has('Test', user1, 'test'), 1)
+        self.assertEquals(has('Test', user2, 'test'), 1)
+        self.assertEquals(has('Test', user3, 'test'), 1)
+        self.assertEquals(has('Test', none, 'test'), 0)
+        # now check function
+        self.assertEquals(has('Test', user3, 'test', itemid='1'), 1)
+        self.assertEquals(has('Test', user3, 'test', itemid='2'), 0)
+        self.assertEquals(has('Test', user2, 'test', itemid='1'), 1)
+        self.assertEquals(has('Test', user2, 'test', itemid='2'), 1)
+        self.assertEquals(has('Test', user1, 'test', itemid='2'), 1)
+        self.assertEquals(has('Test', user1, 'test', itemid='2'), 1)
+        self.assertEquals(has('Test', super, 'test', itemid='1'), 1)
+        self.assertEquals(has('Test', super, 'test', itemid='2'), 1)
+        self.assertEquals(has('Test', none, 'test', itemid='1'), 0)
+        self.assertEquals(has('Test', none, 'test', itemid='2'), 0)
+
+def test_suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(PermissionTest))
+    return suite
+
+if __name__ == '__main__':
+    runner = unittest.TextTestRunner()
+    unittest.main(testRunner=runner)
+
+# vim: set filetype=python sts=4 sw=4 et si :

Added: tracker/vendor/roundup/current/test/test_sqlite.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/test/test_sqlite.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,65 @@
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+# 
+# $Id: test_sqlite.py,v 1.5 2004/11/03 01:34:21 richard Exp $ 
+
+import unittest, os, shutil, time
+from roundup.backends import get_backend, have_backend
+
+from db_test_base import DBTest, ROTest, SchemaTest, ClassicInitTest, config
+
+class sqliteOpener:
+    if have_backend('sqlite'):
+        module = get_backend('sqlite')
+
+    def nuke_database(self):
+        shutil.rmtree(config.DATABASE)
+
+class sqliteDBTest(sqliteOpener, DBTest):
+    pass
+
+class sqliteROTest(sqliteOpener, ROTest):
+    pass
+
+class sqliteSchemaTest(sqliteOpener, SchemaTest):
+    pass
+
+class sqliteClassicInitTest(ClassicInitTest):
+    backend = 'sqlite'
+
+from session_common import RDBMSTest
+class sqliteSessionTest(sqliteOpener, RDBMSTest):
+    pass
+
+def test_suite():
+    suite = unittest.TestSuite()
+    from roundup import backends
+    if not have_backend('sqlite'):
+        print 'Skipping sqlite tests'
+        return suite
+    print 'Including sqlite tests'
+    suite.addTest(unittest.makeSuite(sqliteDBTest))
+    suite.addTest(unittest.makeSuite(sqliteROTest))
+    suite.addTest(unittest.makeSuite(sqliteSchemaTest))
+    suite.addTest(unittest.makeSuite(sqliteClassicInitTest))
+    suite.addTest(unittest.makeSuite(sqliteSessionTest))
+    return suite
+
+if __name__ == '__main__':
+    runner = unittest.TextTestRunner()
+    unittest.main(testRunner=runner)
+

Added: tracker/vendor/roundup/current/test/test_templating.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/test/test_templating.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,252 @@
+import unittest
+from cgi import FieldStorage, MiniFieldStorage
+
+from roundup.cgi.templating import *
+from test_actions import MockNull, true
+
+class MockDatabase(MockNull):
+    def getclass(self, name):
+        return self.classes[name]
+
+class TemplatingTestCase(unittest.TestCase):
+    def setUp(self):
+        self.form = FieldStorage()
+        self.client = MockNull()
+        self.client.db = MockDatabase()
+        self.client.form = self.form
+
+class HTMLDatabaseTestCase(TemplatingTestCase):
+    def test_HTMLDatabase___getitem__(self):
+        db = HTMLDatabase(self.client)
+        self.assert_(isinstance(db['issue'], HTMLClass))
+        # following assertions are invalid
+        # since roundup/cgi/templating.py r1.173.
+        # HTMLItem is function, not class,
+        # but HTMLUserClass and HTMLUser are passed on.
+        # these classes are no more.  they have ceased to be.
+        #self.assert_(isinstance(db['user'], HTMLUserClass))
+        #self.assert_(isinstance(db['issue1'], HTMLItem))
+        #self.assert_(isinstance(db['user1'], HTMLUser))
+
+    def test_HTMLDatabase___getattr__(self):
+        db = HTMLDatabase(self.client)
+        self.assert_(isinstance(db.issue, HTMLClass))
+        # see comment in test_HTMLDatabase___getitem__
+        #self.assert_(isinstance(db.user, HTMLUserClass))
+        #self.assert_(isinstance(db.issue1, HTMLItem))
+        #self.assert_(isinstance(db.user1, HTMLUser))
+
+    def test_HTMLDatabase_classes(self):
+        db = HTMLDatabase(self.client)
+        db._db.classes = {'issue':MockNull(), 'user': MockNull()}
+        db.classes()
+
+class FunctionsTestCase(TemplatingTestCase):
+    def test_lookupIds(self):
+        db = HTMLDatabase(self.client)
+        def lookup(key):
+            if key == 'ok':
+                return '1'
+            if key == 'fail':
+                raise KeyError, 'fail'
+            return key
+        db._db.classes = {'issue': MockNull(lookup=lookup)}
+        prop = MockNull(classname='issue')
+        self.assertEqual(lookupIds(db._db, prop, ['1','2']), ['1','2'])
+        self.assertEqual(lookupIds(db._db, prop, ['ok','2']), ['1','2'])
+        self.assertEqual(lookupIds(db._db, prop, ['ok', 'fail'], 1),
+            ['1', 'fail'])
+        self.assertEqual(lookupIds(db._db, prop, ['ok', 'fail']), ['1'])
+
+    def test_lookupKeys(self):
+        db = HTMLDatabase(self.client)
+        def get(entry, key):
+            return {'1': 'green', '2': 'eggs'}.get(entry, entry)
+        shrubbery = MockNull(get=get)
+        db._db.classes = {'shrubbery': shrubbery}
+        self.assertEqual(lookupKeys(shrubbery, 'spam', ['1','2']),
+            ['green', 'eggs'])
+        self.assertEqual(lookupKeys(shrubbery, 'spam', ['ok','2']), ['ok',
+            'eggs'])
+
+'''
+class HTMLPermissions:
+    def is_edit_ok(self):
+    def is_view_ok(self):
+    def is_only_view_ok(self):
+    def view_check(self):
+    def edit_check(self):
+
+def input_html4(**attrs):
+def input_xhtml(**attrs):
+
+class HTMLInputMixin:
+    def __init__(self):
+
+class HTMLClass(HTMLInputMixin, HTMLPermissions):
+    def __init__(self, client, classname, anonymous=0):
+    def __repr__(self):
+    def __getitem__(self, item):
+    def __getattr__(self, attr):
+    def designator(self):
+    def getItem(self, itemid, num_re=re.compile('-?\d+')):
+    def properties(self, sort=1):
+    def list(self, sort_on=None):
+    def csv(self):
+    def propnames(self):
+    def filter(self, request=None, filterspec={}, sort=(None,None),
+    def classhelp(self, properties=None, label='(list)', width='500',
+    def submit(self, label="Submit New Entry"):
+    def history(self):
+    def renderWith(self, name, **kwargs):
+
+class HTMLItem(HTMLInputMixin, HTMLPermissions):
+    def __init__(self, client, classname, nodeid, anonymous=0):
+    def __repr__(self):
+    def __getitem__(self, item):
+    def __getattr__(self, attr):
+    def designator(self):
+    def is_retired(self):
+    def submit(self, label="Submit Changes"):
+    def journal(self, direction='descending'):
+    def history(self, direction='descending', dre=re.compile('\d+')):
+    def renderQueryForm(self):
+
+class HTMLUserPermission:
+    def is_edit_ok(self):
+    def is_view_ok(self):
+    def _user_perm_check(self, type):
+
+class HTMLUserClass(HTMLUserPermission, HTMLClass):
+
+class HTMLUser(HTMLUserPermission, HTMLItem):
+    def __init__(self, client, classname, nodeid, anonymous=0):
+    def hasPermission(self, permission, classname=_marker):
+
+class HTMLProperty(HTMLInputMixin, HTMLPermissions):
+    def __init__(self, client, classname, nodeid, prop, name, value,
+    def __repr__(self):
+    def __str__(self):
+    def __cmp__(self, other):
+    def is_edit_ok(self):
+    def is_view_ok(self):
+
+class StringHTMLProperty(HTMLProperty):
+    def _hyper_repl(self, match):
+    def hyperlinked(self):
+    def plain(self, escape=0, hyperlink=0):
+    def stext(self, escape=0):
+    def field(self, size = 30):
+    def multiline(self, escape=0, rows=5, cols=40):
+    def email(self, escape=1):
+
+class PasswordHTMLProperty(HTMLProperty):
+    def plain(self):
+    def field(self, size = 30):
+    def confirm(self, size = 30):
+
+class NumberHTMLProperty(HTMLProperty):
+    def plain(self):
+    def field(self, size = 30):
+    def __int__(self):
+    def __float__(self):
+
+class BooleanHTMLProperty(HTMLProperty):
+    def plain(self):
+    def field(self):
+
+class DateHTMLProperty(HTMLProperty):
+    def plain(self):
+    def now(self):
+    def field(self, size = 30):
+    def reldate(self, pretty=1):
+    def pretty(self, format=_marker):
+    def local(self, offset):
+
+class IntervalHTMLProperty(HTMLProperty):
+    def plain(self):
+    def pretty(self):
+    def field(self, size = 30):
+
+class LinkHTMLProperty(HTMLProperty):
+    def __init__(self, *args, **kw):
+    def __getattr__(self, attr):
+    def plain(self, escape=0):
+    def field(self, showid=0, size=None):
+    def menu(self, size=None, height=None, showid=0, additional=[],
+
+class MultilinkHTMLProperty(HTMLProperty):
+    def __init__(self, *args, **kwargs):
+    def __len__(self):
+    def __getattr__(self, attr):
+    def __getitem__(self, num):
+    def __contains__(self, value):
+    def reverse(self):
+    def plain(self, escape=0):
+    def field(self, size=30, showid=0):
+    def menu(self, size=None, height=None, showid=0, additional=[],
+
+def make_sort_function(db, classname, sort_on=None):
+    def sortfunc(a, b):
+
+def find_sort_key(linkcl):
+
+def handleListCGIValue(value):
+
+class ShowDict:
+    def __init__(self, columns):
+    def __getitem__(self, name):
+
+class HTMLRequest(HTMLInputMixin):
+    def __init__(self, client):
+    def _post_init(self):
+    def updateFromURL(self, url):
+    def update(self, kwargs):
+    def description(self):
+    def __str__(self):
+    def indexargs_form(self, columns=1, sort=1, group=1, filter=1,
+    def indexargs_url(self, url, args):
+    def base_javascript(self):
+    def batch(self):
+
+class Batch(ZTUtils.Batch):
+    def __init__(self, client, sequence, size, start, end=0, orphan=0,
+    def __getitem__(self, index):
+    def propchanged(self, property):
+    def previous(self):
+    def next(self):
+
+class TemplatingUtils:
+    def __init__(self, client):
+    def Batch(self, sequence, size, start, end=0, orphan=0, overlap=0):
+
+class NoTemplate(Exception):
+class Unauthorised(Exception):
+    def __init__(self, action, klass):
+    def __str__(self):
+def find_template(dir, name, extension):
+
+class Templates:
+    def __init__(self, dir):
+    def precompileTemplates(self):
+    def get(self, name, extension=None):
+    def __getitem__(self, name):
+
+class RoundupPageTemplate(PageTemplate.PageTemplate):
+    def getContext(self, client, classname, request):
+    def render(self, client, classname, request, **options):
+    def __repr__(self):
+'''
+
+
+def test_suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(HTMLDatabaseTestCase))
+    suite.addTest(unittest.makeSuite(FunctionsTestCase))
+    return suite
+
+if __name__ == '__main__':
+    runner = unittest.TextTestRunner()
+    unittest.main(testRunner=runner)
+
+# vim: set et sts=4 sw=4 :

Added: tracker/vendor/roundup/current/test/test_token.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/test/test_token.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,61 @@
+#
+# Copyright (c) 2001 Richard Jones
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# This module 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.
+#
+# $Id: test_token.py,v 1.3 2003/10/25 22:53:26 richard Exp $
+
+import unittest, time
+
+from roundup.token import token_split
+
+class TokenTestCase(unittest.TestCase):
+    def testValid(self):
+        l = token_split('hello world')
+        self.assertEqual(l, ['hello', 'world'])
+
+    def testIgnoreExtraSpace(self):
+        l = token_split('hello  world ')
+        self.assertEqual(l, ['hello', 'world'])
+
+    def testQuoting(self):
+        l = token_split('"hello world"')
+        self.assertEqual(l, ['hello world'])
+        l = token_split("'hello world'")
+        self.assertEqual(l, ['hello world'])
+
+    def testEmbedQuote(self):
+        l = token_split(r'Roch\'e Compaan')
+        self.assertEqual(l, ["Roch'e", "Compaan"])
+        l = token_split('address="1 2 3"')
+        self.assertEqual(l, ['address=1 2 3'])
+
+    def testEscaping(self):
+        l = token_split('"Roch\'e" Compaan')
+        self.assertEqual(l, ["Roch'e", "Compaan"])
+        l = token_split(r'hello\ world')
+        self.assertEqual(l, ['hello world'])
+        l = token_split(r'\\')
+        self.assertEqual(l, ['\\'])
+        l = token_split(r'\n')
+        self.assertEqual(l, ['\n'])
+
+    def testBadQuote(self):
+        self.assertRaises(ValueError, token_split, '"hello world')
+        self.assertRaises(ValueError, token_split, "Roch'e Compaan")
+
+def test_suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(TokenTestCase))
+    return suite
+
+if __name__ == '__main__':
+    runner = unittest.TextTestRunner()
+    unittest.main(testRunner=runner)
+
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/test/test_tsearch2.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/test/test_tsearch2.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,124 @@
+#
+# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
+# This module is free software, and you may redistribute it and/or modify
+# under the same terms as Python, so long as this copyright message and
+# disclaimer are retained in their original form.
+#
+# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
+# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
+# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
+# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
+# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
+# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
+#
+# $Id: test_tsearch2.py,v 1.1 2004/12/16 22:22:55 jlgijsbers Exp $
+
+import unittest
+
+from roundup.hyperdb import DatabaseError
+
+from db_test_base import DBTest, ROTest, config, SchemaTest, ClassicInitTest
+
+from roundup.backends import get_backend, have_backend
+
+class tsearch2Opener:
+    if have_backend('tsearch2'):
+        module = get_backend('tsearch2')
+
+    def setUp(self):
+        pass
+
+    def tearDown(self):
+        self.nuke_database()
+
+    def nuke_database(self):
+        # clear out the database - easiest way is to nuke and re-create it
+        self.module.db_nuke(config)
+
+class tsearch2DBTest(tsearch2Opener, DBTest):
+    def setUp(self):
+        tsearch2Opener.setUp(self)
+        DBTest.setUp(self)
+
+    def tearDown(self):
+        DBTest.tearDown(self)
+        tsearch2Opener.tearDown(self)
+
+    def testFilteringIntervalSort(self):
+        # Tsearch2 sorts NULLs differently to other databases (others
+        # treat it as lower than real values, PG treats it as higher)
+        ae, filt = self.filteringSetup()
+        # ascending should sort None, 1:10, 1d
+        ae(filt(None, {}, ('+','foo'), (None,None)), ['4', '1', '2', '3'])
+        # descending should sort 1d, 1:10, None
+        ae(filt(None, {}, ('-','foo'), (None,None)), ['3', '2', '1', '4'])
+
+    def testTransactions(self):
+        # XXX: in its current form, this test doesn't make sense for tsearch2.
+        # It tests the transactions mechanism by counting the number of files
+        # in the FileStorage. As tsearch2 doesn't use the FileStorage, this
+        # fails. The test should probably be rewritten with some other way of
+        # checking rollbacks/commits.
+        pass
+
+class tsearch2ROTest(tsearch2Opener, ROTest):
+    def setUp(self):
+        tsearch2Opener.setUp(self)
+        ROTest.setUp(self)
+
+    def tearDown(self):
+        ROTest.tearDown(self)
+        tsearch2Opener.tearDown(self)
+
+class tsearch2SchemaTest(tsearch2Opener, SchemaTest):
+    def setUp(self):
+        tsearch2Opener.setUp(self)
+        SchemaTest.setUp(self)
+
+    def tearDown(self):
+        SchemaTest.tearDown(self)
+        tsearch2Opener.tearDown(self)
+
+class tsearch2ClassicInitTest(tsearch2Opener, ClassicInitTest):
+    backend = 'tsearch2'
+    def setUp(self):
+        tsearch2Opener.setUp(self)
+        ClassicInitTest.setUp(self)
+
+    def tearDown(self):
+        ClassicInitTest.tearDown(self)
+        tsearch2Opener.tearDown(self)
+
+from session_common import RDBMSTest
+class tsearch2SessionTest(tsearch2Opener, RDBMSTest):
+    def setUp(self):
+        tsearch2Opener.setUp(self)
+        RDBMSTest.setUp(self)
+    def tearDown(self):
+        RDBMSTest.tearDown(self)
+        tsearch2Opener.tearDown(self)
+
+def test_suite():
+    suite = unittest.TestSuite()
+    if not have_backend('tsearch2'):
+        print "Skipping tsearch2 tests"
+        return suite
+
+    # make sure we start with a clean slate
+    if tsearch2Opener.module.db_exists(config):
+        tsearch2Opener.module.db_nuke(config, 1)
+
+    # TODO: Check if we can run postgresql tests
+    print 'Including tsearch2 tests'
+    suite.addTest(unittest.makeSuite(tsearch2DBTest))
+    suite.addTest(unittest.makeSuite(tsearch2ROTest))
+    suite.addTest(unittest.makeSuite(tsearch2SchemaTest))
+    suite.addTest(unittest.makeSuite(tsearch2ClassicInitTest))
+    suite.addTest(unittest.makeSuite(tsearch2SessionTest))
+    return suite
+
+# vim: set et sts=4 sw=4 :

Added: tracker/vendor/roundup/current/tools/.cvsignore
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/tools/.cvsignore	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,4 @@
+*.pyc
+*.pyo
+localconfig.py
+build

Added: tracker/vendor/roundup/current/tools/base64
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/tools/base64	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,3 @@
+#!/usr/bin/env python
+import zlib, base64, sys
+print base64.encodestring(zlib.compress(sys.stdin.read()))

Added: tracker/vendor/roundup/current/tools/fixroles.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/tools/fixroles.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,40 @@
+import sys
+
+from roundup import admin
+
+class AdminTool(admin.AdminTool):
+    def __init__(self):
+        self.commands = admin.CommandDict()
+        for k in AdminTool.__dict__.keys():
+            if k[:3] == 'do_':
+                self.commands[k[3:]] = getattr(self, k)
+        self.help = {}
+        for k in AdminTool.__dict__.keys():
+            if k[:5] == 'help_':
+                self.help[k[5:]] = getattr(self, k)
+        self.instance_home = ''
+        self.db = None
+
+    def do_fixroles(self, args):
+        '''Usage: fixroles
+        Set the roles property for all users to reasonable defaults.
+
+        The admin user gets "Admin", the anonymous user gets "Anonymous"
+        and all other users get "User".
+        '''
+        # get the user class
+        cl = self.get_class('user')
+        for userid in cl.list():
+            username = cl.get(userid, 'username')
+            if username == 'admin':
+                roles = 'Admin'
+            elif username == 'anonymous':
+                roles = 'Anonymous'
+            else:
+                roles = 'User'
+            cl.set(userid, roles=roles)
+        return 0
+
+if __name__ == '__main__':
+    tool = AdminTool()
+    sys.exit(tool.main())

Added: tracker/vendor/roundup/current/tools/load_tracker.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/tools/load_tracker.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,96 @@
+#! /usr/bin/env python
+# $Id: load_tracker.py,v 1.6 2005/06/08 02:24:06 anthonybaxter Exp $
+
+'''
+Usage: %s <tracker home> <N>
+
+Load up the indicated tracker with N issues and N/100 users.
+'''
+
+import sys, os, random
+from roundup import instance
+
+# open the instance
+if len(sys.argv) < 2:
+    print "Error: Not enough arguments"
+    print __doc__.strip()%(sys.argv[0])
+    sys.exit(1)
+tracker_home = sys.argv[1]
+N = int(sys.argv[2])
+
+# open the tracker
+tracker = instance.open(tracker_home)
+db = tracker.open('admin')
+
+priorities = db.priority.list()
+statuses = db.status.list()
+resolved_id = db.status.lookup('resolved')
+statuses.remove(resolved_id)
+
+names = ['alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta', 'eta', 
+'theta', 'iota', 'kappa', 'lambda', 'mu', 'nu', 'xi', 'omicron', 'pi',
+'rho']
+
+titles = '''Lorem ipsum dolor sit amet, consectetuer adipiscing elit.
+Duis nibh purus, bibendum sed, condimentum ut, bibendum ut, risus.
+Fusce pede enim, nonummy sit amet, dapibus a, blandit eget, metus.
+Nulla risus.
+Vivamus tincidunt.
+Donec consequat convallis quam.
+Sed convallis vehicula felis.
+Aliquam laoreet, dui quis pharetra vehicula, magna justo.
+Euismod felis, eu adipiscing eros metus id tortor.
+Suspendisse et turpis.
+Aenean non felis.
+Nam egestas eros.
+Integer tellus quam, mattis ac, vestibulum sed, egestas quis, mauris.
+Nulla tincidunt diam sit amet dui.
+Nam odio mauris, dignissim vitae, eleifend eu, consectetuer id, risus.
+Suspendisse potenti.
+Donec tincidunt.
+Vestibulum gravida.
+Fusce luctus, neque id mattis fringilla, purus pede sodales pede.
+Quis ultricies urna odio sed orci.'''.splitlines()
+
+try:
+    try:
+        db.user.lookup('alpha0')
+    except:
+        # add some users
+        M = N/100
+        for i in range(M):
+            print '\ruser', i, '       ',
+            sys.stdout.flush()
+            if i/17 == 0:
+                db.user.create(username=names[i%17])
+            else:
+                db.user.create(username=names[i%17]+str(i/17))
+
+    # assignable user list
+    users = db.user.list()
+    users.remove(db.user.lookup('anonymous'))
+    print
+
+    # now create the issues
+    for i in range(N):
+        print '\rissue', i, '       ',
+        sys.stdout.flush()
+        # in practise, about 90% of issues are resolved
+        if random.random() > .9:
+            status = random.choice(statuses)
+        else:
+            status = resolved_id
+        db.issue.create(
+            title=random.choice(titles),
+            priority=random.choice(priorities),
+            status=status,
+            assignedto=random.choice(users))
+        if not i%1000:
+            db.commit()
+    print
+
+    db.commit()
+finally:
+    db.close()
+
+# vim: set filetype=python ts=4 sw=4 et si

Added: tracker/vendor/roundup/current/tools/migrate-queries.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/tools/migrate-queries.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,42 @@
+#! /usr/bin/env python
+'''
+migrate-queries <instance-home> [<instance-home> *]
+
+Migrate old queries in the specified instances to Roundup 0.6.0+ by
+removing the leading ? from their URLs. 0.6.0+ queries do not carry a
+leading ?; it is added by the 0.6.0 templating, so old queries lead
+to query URLs with a double leading ?? and a consequent 404 Not Found.
+'''
+__author__ = 'James Kew <jkew at mediabright.co.uk>'
+
+import sys
+import roundup.instance
+
+if len(sys.argv) == 1:
+    print __doc__
+    sys.exit(1)
+
+# Iterate over all instance homes specified in argv.
+for home in sys.argv[1:]:
+    # Do some basic exception handling to catch bad arguments.
+    try:
+        instance = roundup.instance.open(home)
+    except:
+        print 'Cannot open instance home directory %s!' % home
+        continue
+
+    db = instance.open('admin')
+
+    print 'Migrating active queries in %s (%s):'%(
+        instance.config.TRACKER_NAME, home)
+    for query in db.query.list():
+        url = db.query.get(query, 'url')
+        if url[0] == '?':
+            url = url[1:]
+            print '  Migrating query%s (%s)'%(query,
+                db.query.get(query, 'name'))
+            db.query.set(query, url=url)
+
+    db.commit()
+    db.close()
+

Added: tracker/vendor/roundup/current/tools/pygettext.py
==============================================================================
--- (empty file)
+++ tracker/vendor/roundup/current/tools/pygettext.py	Sun Nov  5 21:30:25 2006
@@ -0,0 +1,666 @@
+#! /usr/bin/env python
+# Originally written by Barry Warsaw <barry at zope.com>
+#
+# Minimally patched to make it even more xgettext compatible 
+# by Peter Funk <pf at artcom-gmbh.de>
+#
+# 2001-12-18 Jürgen Hermann <jh at web.de>
+# Added checks that _() only contains string literals, and
+# command line args are resolved to module lists, i.e. you
+# can now pass a filename, a module or package name, or a
+# directory (including globbing chars, important for Win32).
+# Made docstring fit in 80 chars wide displays using pydoc.
+#
+
+# for selftesting
+try:
+    import fintl
+    _ = fintl.gettext
+except ImportError:
+    _ = lambda s: s
+
+__doc__ = _("""pygettext -- Python equivalent of xgettext(1)
+
+Many systems (Solaris, Linux, Gnu) provide extensive tools that ease the
+internationalization of C programs. Most of these tools are independent of
+the programming language and can be used from within Python programs.
+Martin von Loewis' work[1] helps considerably in this regard. 
+
+There's one problem though; xgettext is the program that scans source code
+looking for message strings, but it groks only C (or C++). Python
+introduces a few wrinkles, such as dual quoting characters, triple quoted
+strings, and raw strings. xgettext understands none of this. 
+
+Enter pygettext, which uses Python's standard tokenize module to scan
+Python source code, generating .pot files identical to what GNU xgettext[2]
+generates for C and C++ code. From there, the standard GNU tools can be
+used. 
+
+A word about marking Python strings as candidates for translation. GNU
+xgettext recognizes the following keywords: gettext, dgettext, dcgettext,
+and gettext_noop. But those can be a lot of text to include all over your
+code. C and C++ have a trick: they use the C preprocessor. Most
+internationalized C source includes a #define for gettext() to _() so that
+what has to be written in the source is much less. Thus these are both
+translatable strings: 
+
+    gettext("Translatable String")
+    _("Translatable String")
+
+Python of course has no preprocessor so this doesn't work so well.  Thus,
+pygettext searches only for _() by default, but see the -k/--keyword flag
+below for how to augment this.
+
+ [1] http://www.python.org/workshops/1997-10/proceedings/loewis.html
+ [2] http://www.gnu.org/software/gettext/gettext.html
+
+NOTE: pygettext attempts to be option and feature compatible with GNU
+xgettext where ever possible. However some options are still missing or are
+not fully implemented. Also, xgettext's use of command line switches with
+option arguments is broken, and in these cases, pygettext just defines
+additional switches. 
+
+Usage: pygettext [options] inputfile ...
+
+Options:
+
+    -a
+    --extract-all
+        Extract all strings.
+
+    -d name
+    --default-domain=name
+        Rename the default output file from messages.pot to name.pot.
+
+    -E
+    --escape
+        Replace non-ASCII characters with octal escape sequences.
+
+    -D
+    --docstrings
+        Extract module, class, method, and function docstrings.  These do
+        not need to be wrapped in _() markers, and in fact cannot be for
+        Python to consider them docstrings. (See also the -X option).
+
+    -h
+    --help
+        Print this help message and exit.
+
+    -k word
+    --keyword=word
+        Keywords to look for in addition to the default set, which are:
+        %(DEFAULTKEYWORDS)s
+
+        You can have multiple -k flags on the command line.
+
+    -K
+    --no-default-keywords
+        Disable the default set of keywords (see above).  Any keywords
+        explicitly added with the -k/--keyword option are still recognized.
+
+    --no-location
+        Do not write filename/lineno location comments.
+
+    -n
+    --add-location
+        Write filename/lineno location comments indicating where each
+        extracted string is found in the source.  These lines appear before
+        each msgid.  The style of comments is controlled by the -S/--style
+        option.  This is the default.
+
+    -o filename
+    --output=filename
+        Rename the default output file from messages.pot to filename.  If
+        filename is `-' then the output is sent to standard out.
+
+    -p dir
+    --output-dir=dir
+        Output files will be placed in directory dir.
+
+    -S stylename
+    --style stylename
+        Specify which style to use for location comments.  Two styles are
+        supported:
+
+        Solaris  # File: filename, line: line-number
+        GNU      #: filename:line
+
+        The style name is case insensitive.  GNU style is the default.
+
+    -v
+    --verbose
+        Print the names of the files being processed.
+
+    -V
+    --version
+        Print the version of pygettext and exit.
+
+    -w columns
+    --width=columns
+        Set width of output to columns.
+
+    -x filename
+    --exclude-file=filename
+        Specify a file that contains a list of strings that are not be
+        extracted from the input files.  Each string to be excluded must
+        appear on a line by itself in the file.
+
+    -X filename
+    --no-docstrings=filename
+        Specify a file that contains a list of files (one per line) that
+        should not have their docstrings extracted.  This is only useful in
+        conjunction with the -D option above.
+
+If `inputfile' is -, standard input is read.
+""")
+
+import os
+import sys
+import time
+import getopt
+import token
+import tokenize
+import operator
+
+__version__ = '1.5'
+
+default_keywords = ['_']
+DEFAULTKEYWORDS = ', '.join(default_keywords)
+
+EMPTYSTRING = ''
+
+
+
+# The normal pot-file header. msgmerge and Emacs's po-mode work better if it's
+# there.
+pot_header = _('''\
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR ORGANIZATION
+# FIRST AUTHOR <EMAIL at ADDRESS>, YEAR.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\\n"
+"POT-Creation-Date: %(time)s\\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n"
+"Last-Translator: FULL NAME <EMAIL at ADDRESS>\\n"
+"Language-Team: LANGUAGE <LL at li.org>\\n"
+"MIME-Version: 1.0\\n"
+"Content-Type: text/plain; charset=CHARSET\\n"
+"Content-Transfer-Encoding: ENCODING\\n"
+"Generated-By: pygettext.py %(version)s\\n"
+
+''')
+
+
+def usage(code, msg=''):
+    print >> sys.stderr, __doc__ % globals()
+    if msg:
+        print >> sys.stderr, msg
+    sys.exit(code)
+
+
+
+escapes = []
+
+def make_escapes(pass_iso8859):
+    global escapes
+    if pass_iso8859:
+        # Allow iso-8859 characters to pass through so that e.g. 'msgid
+        # "Höhe"' would result not result in 'msgid "H\366he"'.  Otherwise we
+        # escape any character outside the 32..126 range.
+        mod = 128
+    else:
+        mod = 256
+    for i in range(256):
+        if 32 <= (i % mod) <= 126:
+            escapes.append(chr(i))
+        else:
+            escapes.append("\\%03o" % i)
+    escapes[ord('\\')] = '\\\\'
+    escapes[ord('\t')] = '\\t'
+    escapes[ord('\r')] = '\\r'
+    escapes[ord('\n')] = '\\n'
+    escapes[ord('\"')] = '\\"'
+
+
+def escape(s):
+    global escapes
+    s = list(s)
+    for i in range(len(s)):
+        s[i] = escapes[ord(s[i])]
+    return EMPTYSTRING.join(s)
+
+
+def safe_eval(s):
+    # unwrap quotes, safely
+    return eval(s, {'__builtins__':{}}, {})
+
+
+def normalize(s):
+    # This converts the various Python string types into a format that is
+    # appropriate for .po files, namely much closer to C style.
+    lines = s.split('\n')
+    if len(lines) == 1:
+        s = '"' + escape(s) + '"'
+    else:
+        if not lines[-1]:
+            del lines[-1]
+            lines[-1] = lines[-1] + '\n'
+        for i in range(len(lines)):
+            lines[i] = escape(lines[i])
+        lineterm = '\\n"\n"'
+        s = '""\n"' + lineterm.join(lines) + '"'
+    return s
+
+
+def containsAny(str, set):
+    """ Check whether 'str' contains ANY of the chars in 'set'
+    """
+    return 1 in [c in str for c in set]
+
+
+def _visit_pyfiles(list, dirname, names):
+    """ Helper for getFilesForName().
+    """
+    # get extension for python source files
+    if not globals().has_key('_py_ext'):
+        import imp
+        global _py_ext
+        _py_ext = [triple[0] for triple in imp.get_suffixes() if triple[2] == imp.PY_SOURCE][0]
+
+    # don't recurse into CVS directories
+    if 'CVS' in names:
+        names.remove('CVS')
+
+    # add all *.py files to list
+    list.extend(
+        [os.path.join(dirname, file)
+            for file in names
+                if os.path.splitext(file)[1] == _py_ext])
+
+
+def _get_modpkg_path(dotted_name, pathlist=None):
+    """ Get the filesystem path for a module or a package.
+
+        Return the file system path to a file for a module,
+        and to a directory for a package. Return None if
+        the name is not found, or is a builtin or extension module.
+    """
+    import imp
+
+    # split off top-most name
+    parts = dotted_name.split('.', 1)
+
+    if len(parts) > 1:
+        # we have a dotted path, import top-level package
+        try:
+            file, pathname, description = imp.find_module(parts[0], pathlist)
+            if file: file.close()
+        except ImportError:
+            return None
+
+        # check if it's indeed a package
+        if description[2] == imp.PKG_DIRECTORY:
+            # recursively handle the remaining name parts
+            pathname = _get_modpkg_path(parts[1], [pathname])
+        else:
+            pathname = None
+    else:
+        # plain name
+        try:
+            file, pathname, description = imp.find_module(dotted_name, pathlist)
+            if file: file.close()
+            if description[2] not in [imp.PY_SOURCE, imp.PKG_DIRECTORY]:
+                pathname = None
+        except ImportError:
+            pathname = None
+
+    return pathname
+
+
+def getFilesForName(name):
+    """ Get a list of module files for a filename, a module or package name,
+        or a directory.
+    """
+    import imp
+
+    if not os.path.exists(name):
+        # check for glob chars
+        if containsAny(name, "*?[]"):
+            import glob
+            files = glob.glob(name)
+            list = []
+            for file in files:
+                list.extend(getFilesForName(file))
+            return list
+
+        # try to find module or package
+        name = _get_modpkg_path(name)
+        if not name:
+            return []
+
+    if os.path.isdir(name):
+        # find all python files in directory
+        list = []
+        os.path.walk(name, _visit_pyfiles, list)
+        return list
+    elif os.path.exists(name):
+        # a single file
+        return [name]
+
+    return []
+
+
+class TokenEater:
+    def __init__(self, options):
+        self.__options = options
+        self.__messages = {}
+        self.__state = self.__waiting
+        self.__data = []
+        self.__lineno = -1
+        self.__freshmodule = 1
+        self.__curfile = None
+
+    def __call__(self, ttype, tstring, stup, etup, line):
+        # dispatch
+##        import token
+##        print >> sys.stderr, 'ttype:', token.tok_name[ttype], \
+##              'tstring:', tstring
+        self.__state(ttype, tstring, stup[0])
+
+    def __waiting(self, ttype, tstring, lineno):
+        opts = self.__options
+        # Do docstring extractions, if enabled
+        if opts.docstrings and not opts.nodocstrings.get(self.__curfile):
+            # module docstring?
+            if self.__freshmodule:
+                if ttype == tokenize.STRING:
+                    self.__addentry(safe_eval(tstring), lineno, isdocstring=1)
+                    self.__freshmodule = 0
+                elif ttype not in (tokenize.COMMENT, tokenize.NL):
+                    self.__freshmodule = 0
+                return
+            # class docstring?
+            if ttype == tokenize.NAME and tstring in ('class', 'def'):
+                self.__state = self.__suiteseen
+                return
+        if ttype == tokenize.NAME and tstring in opts.keywords:
+            self.__state = self.__keywordseen
+
+    def __suiteseen(self, ttype, tstring, lineno):
+        # ignore anything until we see the colon
+        if ttype == tokenize.OP and tstring == ':':
+            self.__state = self.__suitedocstring
+
+    def __suitedocstring(self, ttype, tstring, lineno):
+        # ignore any intervening noise
+        if ttype == tokenize.STRING:
+            self.__addentry(safe_eval(tstring), lineno, isdocstring=1)
+            self.__state = self.__waiting
+        elif ttype not in (tokenize.NEWLINE, tokenize.INDENT,
+                           tokenize.COMMENT):
+            # there was no class docstring
+            self.__state = self.__waiting
+
+    def __keywordseen(self, ttype, tstring, lineno):
+        if ttype == tokenize.OP and tstring == '(':
+            self.__data = []
+            self.__lineno = lineno
+            self.__state = self.__openseen
+        else:
+            self.__state = self.__waiting
+
+    def __openseen(self, ttype, tstring, lineno):
+        if ttype == tokenize.OP and tstring == ')':
+            # We've seen the last of the translatable strings.  Record the
+            # line number of the first line of the strings and update the list 
+            # of messages seen.  Reset state for the next batch.  If there
+            # were no strings inside _(), then just ignore this entry.
+            if self.__data:
+                self.__addentry(EMPTYSTRING.join(self.__data))
+            self.__state = self.__waiting
+        elif ttype == tokenize.STRING:
+            self.__data.append(safe_eval(tstring))
+        elif ttype not in [tokenize.COMMENT, token.INDENT, token.DEDENT,
+                           token.NEWLINE, tokenize.NL]:
+            # warn if we see anything else than STRING or whitespace
+            print >>sys.stderr, _('*** %(file)s:%(lineno)s: Seen unexpected token "%(token)s"') % {
+                'token': tstring, 'file': self.__curfile, 'lineno': self.__lineno}
+            self.__state = self.__waiting
+
+    def __addentry(self, msg, lineno=None, isdocstring=0):
+        if lineno is None:
+            lineno = self.__lineno
+        if not msg in self.__options.toexclude:
+            entry = (self.__curfile, lineno)
+            self.__messages.setdefault(msg, {})[entry] = isdocstring
+
+    def set_filename(self, filename):
+        self.__curfile = filename
+        self.__freshmodule = 1
+
+    def write(self, fp):
+        options = self.__options
+        timestamp = time.ctime(time.time())
+        # The time stamp in the header doesn't have the same format as that
+        # generated by xgettext...
+        print >> fp, pot_header % {'time': timestamp, 'version': __version__}
+        # Sort the entries.  First sort each particular entry's keys, then
+        # sort all the entries by their first item.
+        reverse = {}
+        for k, v in self.__messages.items():
+            keys = v.keys()
+            keys.sort()
+            reverse.setdefault(tuple(keys), []).append((k, v))
+        rkeys = reverse.keys()
+        rkeys.sort()
+        for rkey in rkeys:
+            rentries = reverse[rkey]
+            rentries.sort()
+            for k, v in rentries:
+                isdocstring = 0
+                # If the entry was gleaned out of a docstring, then add a
+                # comment stating so.  This is to aid translators who may wish
+                # to skip translating some unimportant docstrings.
+                if reduce(operator.__add__, v.values()):
+                    isdocstring = 1
+                # k is the message string, v is a dictionary-set of (filename,
+                # lineno) tuples.  We want to sort the entries in v first by
+                # file name and then by line number.
+                v = v.keys()
+                v.sort()
+                if not options.writelocations:
+                    pass
+                # location comments are different b/w Solaris and GNU:
+                elif options.locationstyle == options.SOLARIS:
+                    for filename, lineno in v:
+                        d = {'filename': filename, 'lineno': lineno}
+                        print >>fp, _(
+                            '# File: %(filename)s, line: %(lineno)d') % d
+                elif options.locationstyle == options.GNU:
+                    # fit as many locations on one line, as long as the
+                    # resulting line length doesn't exceeds 'options.width'
+                    locline = '#:'
+                    for filename, lineno in v:
+                        d = {'filename': filename, 'lineno': lineno}
+                        s = _(' %(filename)s:%(lineno)d') % d
+                        if len(locline) + len(s) <= options.width:
+                            locline = locline + s
+                        else:
+                            print >> fp, locline
+                            locline = "#:" + s
+                    if len(locline) > 2:
+                        print >> fp, locline
+                if isdocstring:
+                    print >> fp, '#, docstring'
+                print >> fp, 'msgid', normalize(k)
+                print >> fp, 'msgstr ""\n'
+
+
+
+def main():
+    global default_keywords
+    try:
+        opts, args = getopt.getopt(
+            sys.argv[1:],
+            'ad:DEhk:Kno:p:S:Vvw:x:X:',
+            ['extract-all', 'default-domain=', 'escape', 'help',
+             'keyword=', 'no-default-keywords',
+             'add-location', 'no-location', 'output=', 'output-dir=',
+             'style=', 'verbose', 'version', 'width=', 'exclude-file=',
+             'docstrings', 'no-docstrings',
+             ])
+    except getopt.error, msg:
+        usage(1, msg)
+
+    # for holding option values
+    class Options:
+        # constants
+        GNU = 1
+        SOLARIS = 2
+        # defaults
+        extractall = 0 # FIXME: currently this option has no effect at all.
+        escape = 0
+        keywords = []
+        outpath = ''
+        outfile = 'messages.pot'
+        writelocations = 1
+        locationstyle = GNU
+        verbose = 0
+        width = 78
+        excludefilename = ''
+        docstrings = 0
+        nodocstrings = {}
+
+    options = Options()
+    locations = {'gnu' : options.GNU,
+                 'solaris' : options.SOLARIS,
+                 }
+
+    # parse options
+    for opt, arg in opts:
+        if opt in ('-h', '--help'):
+            usage(0)
+        elif opt in ('-a', '--extract-all'):
+            options.extractall = 1
+        elif opt in ('-d', '--default-domain'):
+            options.outfile = arg + '.pot'
+        elif opt in ('-E', '--escape'):
+            options.escape = 1
+        elif opt in ('-D', '--docstrings'):
+            options.docstrings = 1
+        elif opt in ('-k', '--keyword'):
+            options.keywords.append(arg)
+        elif opt in ('-K', '--no-default-keywords'):
+            default_keywords = []
+        elif opt in ('-n', '--add-location'):
+            options.writelocations = 1
+        elif opt in ('--no-location',):
+            options.writelocations = 0
+        elif opt in ('-S', '--style'):
+            options.locationstyle = locations.get(arg.lower())
+            if options.locationstyle is None:
+                usage(1, _('Invalid value for --style: %s') % arg)
+        elif opt in ('-o', '--output'):
+            options.outfile = arg
+        elif opt in ('-p', '--output-dir'):
+            options.outpath = arg
+        elif opt in ('-v', '--verbose'):
+            options.verbose = 1
+        elif opt in ('-V', '--version'):
+            print _('pygettext.py (xgettext for Python) %s') % __version__
+            sys.exit(0)
+        elif opt in ('-w', '--width'):
+            try:
+                options.width = int(arg)
+            except ValueError:
+                usage(1, _('--width argument must be an integer: %s') % arg)
+        elif opt in ('-x', '--exclude-file'):
+            options.excludefilename = arg
+        elif opt in ('-X', '--no-docstrings'):
+            fp = open(arg)
+            try:
+                while 1:
+                    line = fp.readline()
+                    if not line:
+                        break
+                    options.nodocstrings[line[:-1]] = 1
+            finally:
+                fp.close()
+
+    # calculate escapes
+    make_escapes(options.escape)
+
+    # calculate all keywords
+    options.keywords.extend(default_keywords)
+
+    # initialize list of strings to exclude
+    if options.excludefilename:
+        try:
+            fp = open(options.excludefilename)
+            options.toexclude = fp.readlines()
+            fp.close()
+        except IOError:
+            print >> sys.stderr, _(
+                "Can't read --exclude-file: %s") % options.excludefilename
+            sys.exit(1)
+    else:
+        options.toexclude = []
+
+    # resolve args to module lists
+    expanded = []
+    for arg in args:
+        if arg == '-':
+            expanded.append(arg)
+        else:
+            expanded.extend(getFilesForName(arg))
+    args = expanded
+
+    # slurp through all the files
+    eater = TokenEater(options)
+    for filename in args:
+        if filename == '-':
+            if options.verbose:
+                print _('Reading standard input')
+            fp = sys.stdin
+            closep = 0
+        else:
+            if options.verbose:
+                print _('Working on %s') % filename
+            fp = open(filename)
+            closep = 1
+        try:
+            eater.set_filename(filename)
+            try:
+                tokenize.tokenize(fp.readline, eater)
+            except tokenize.TokenError, e:
+                print >> sys.stderr, '%s: %s, line %d, column %d' % (
+                    e[0], filename, e[1][0], e[1][1])
+        finally:
+            if closep:
+                fp.close()
+
+    # write the output
+    if options.outfile == '-':
+        fp = sys.stdout
+        closep = 0
+    else:
+        if options.outpath:
+            options.outfile = os.path.join(options.outpath, options.outfile)
+        fp = open(options.outfile, 'w')
+        closep = 1
+    try:
+        eater.write(fp)
+    finally:
+        if closep:
+            fp.close()
+
+
+if __name__ == '__main__':
+    main()
+    # some more test strings
+    _(u'a unicode string')
+    _('*** Seen unexpected token "%(token)s"' % {'token': 'test'}) # this one creates a warning
+    _('more' 'than' 'one' 'string')
+


More information about the Python-checkins mailing list