Code

svn repository setup
authorstefan <stefan@57a73879-2fb5-44c3-a270-3262357dd7e2>
Fri, 6 Feb 2009 04:44:58 +0000 (04:44 +0000)
committerstefan <stefan@57a73879-2fb5-44c3-a270-3262357dd7e2>
Fri, 6 Feb 2009 04:44:58 +0000 (04:44 +0000)
git-svn-id: http://svn.roundup-tracker.org/svnroot/roundup/roundup/trunk@4074 57a73879-2fb5-44c3-a270-3262357dd7e2

241 files changed:
BUILD.txt
CHANGES.txt
ChangeLog
I18N_PROGRESS.txt [deleted file]
MANIFEST.in
README.txt
TODO.txt [deleted file]
cgi-bin/roundup.cgi
demo.py
detectors/emailauditor.py
doc/.cvsignore
doc/FAQ.txt
doc/Makefile
doc/admin_guide.txt
doc/announcement.txt
doc/customizing.txt
doc/default.css
doc/design.txt
doc/developers.txt
doc/features.txt
doc/images/edit_issue.png [new file with mode: 0644]
doc/images/index_logged_in.png [new file with mode: 0644]
doc/images/index_logged_out.png [new file with mode: 0644]
doc/images/my_details.png [new file with mode: 0644]
doc/images/new_issue.png [new file with mode: 0644]
doc/images/registration.png [new file with mode: 0644]
doc/index.txt
doc/installation.txt
doc/mysql.txt
doc/overview.txt
doc/postgresql.txt
doc/roundup-demo.1 [new file with mode: 0644]
doc/roundup-mailgw.1
doc/roundup-server.1
doc/roundup-server.ini.example [new file with mode: 0644]
doc/tracker_templates.txt
doc/upgrading.txt
doc/user_guide.txt
doc/whatsnew-0.7.txt
doc/whatsnew-0.8.txt [new file with mode: 0644]
doc/xmlrpc.txt [new file with mode: 0644]
frontends/README.txt
frontends/ZRoundup/ZRoundup.py
frontends/ZRoundup/__init__.py
frontends/ZRoundup/refresh.txt [new file with mode: 0644]
frontends/roundup.cgi [new file with mode: 0755]
locale/.cvsignore [new file with mode: 0644]
locale/GNUmakefile [new file with mode: 0644]
locale/de.po [new file with mode: 0644]
locale/en.po [new file with mode: 0644]
locale/es.po [new file with mode: 0755]
locale/fr.po [new file with mode: 0644]
locale/hu.po [new file with mode: 0644]
locale/it.po [new file with mode: 0644]
locale/lt.po [new file with mode: 0755]
locale/roundup.pot [new file with mode: 0644]
locale/ru.po [new file with mode: 0644]
locale/zh_CN.po [new file with mode: 0644]
locale/zh_TW.po [new file with mode: 0755]
roundup/__init__.py
roundup/admin.py
roundup/backends/__init__.py
roundup/backends/back_anydbm.py
roundup/backends/back_bsddb.py [deleted file]
roundup/backends/back_bsddb3.py [deleted file]
roundup/backends/back_metakit.py [deleted file]
roundup/backends/back_mysql.py
roundup/backends/back_postgresql.py
roundup/backends/back_sqlite.py
roundup/backends/back_tsearch2.py [new file with mode: 0644]
roundup/backends/blobfiles.py
roundup/backends/indexer_common.py [new file with mode: 0644]
roundup/backends/indexer_dbm.py
roundup/backends/indexer_rdbms.py
roundup/backends/indexer_xapian.py [new file with mode: 0644]
roundup/backends/portalocker.py
roundup/backends/rdbms_common.py
roundup/backends/sessions_dbm.py
roundup/backends/sessions_rdbms.py
roundup/backends/tsearch2_setup.py [new file with mode: 0644]
roundup/cgi/PageTemplates/ComputedAttribute.py [deleted file]
roundup/cgi/PageTemplates/Expressions.py
roundup/cgi/PageTemplates/GlobalTranslationService.py [new file with mode: 0644]
roundup/cgi/PageTemplates/MultiMapping.py
roundup/cgi/PageTemplates/PageTemplate.py
roundup/cgi/PageTemplates/PathIterator.py
roundup/cgi/PageTemplates/PythonExpr.py
roundup/cgi/PageTemplates/TALES.py
roundup/cgi/PageTemplates/__init__.py
roundup/cgi/TAL/DummyEngine.py [new file with mode: 0644]
roundup/cgi/TAL/HTMLParser.py
roundup/cgi/TAL/HTMLTALParser.py
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 [new file with mode: 0644]
roundup/cgi/TAL/XMLParser.py
roundup/cgi/TAL/__init__.py
roundup/cgi/TAL/markupbase.py
roundup/cgi/TAL/talgettext.py [new file with mode: 0644]
roundup/cgi/TranslationService.py [new file with mode: 0644]
roundup/cgi/ZTUtils/Iterator.py
roundup/cgi/accept_language.py [new file with mode: 0755]
roundup/cgi/actions.py
roundup/cgi/apache.py [new file with mode: 0644]
roundup/cgi/cgitb.py
roundup/cgi/client.py
roundup/cgi/exceptions.py
roundup/cgi/form_parser.py
roundup/cgi/templating.py
roundup/cgi/wsgi_handler.py [new file with mode: 0644]
roundup/configuration.py [new file with mode: 0644]
roundup/date.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 [new file with mode: 0644]
roundup/password.py
roundup/rcsv.py [deleted file]
roundup/rfc2822.py
roundup/roundupdb.py
roundup/scripts/roundup_demo.py [new file with mode: 0644]
roundup/scripts/roundup_gettext.py [new file with mode: 0644]
roundup/scripts/roundup_mailgw.py
roundup/scripts/roundup_server.py
roundup/scripts/roundup_xmlrpc_server.py [new file with mode: 0644]
roundup/security.py
roundup/support.py [new file with mode: 0644]
roundup/xmlrpc.py [new file with mode: 0644]
run_tests.py
scripts/README.txt
scripts/hyperdb_example.py [new file with mode: 0644]
scripts/imapServer.py [new file with mode: 0644]
scripts/import_sf.py [new file with mode: 0644]
scripts/roundup-reminder
scripts/server-ctl
scripts/weekly-report [new file with mode: 0755]
setup.py
templates/classic/__init__.py [deleted file]
templates/classic/config.py [deleted file]
templates/classic/dbinit.py [deleted file]
templates/classic/detectors/__init__.py [deleted file]
templates/classic/detectors/messagesummary.py
templates/classic/detectors/nosyreaction.py
templates/classic/detectors/userauditor.py
templates/classic/extensions/README.txt [new file with mode: 0644]
templates/classic/html/_generic.404.html [new file with mode: 0644]
templates/classic/html/_generic.calendar.html [new file with mode: 0644]
templates/classic/html/_generic.collision.html
templates/classic/html/_generic.help-empty.html [new file with mode: 0644]
templates/classic/html/_generic.help-list.html [new file with mode: 0644]
templates/classic/html/_generic.help-search.html [new file with mode: 0644]
templates/classic/html/_generic.help-submit.html [new file with mode: 0644]
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.html [new file with mode: 0644]
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.help-search.html [new file with mode: 0644]
templates/classic/html/user.help.html [new file with mode: 0644]
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/html/user_utils.js [new file with mode: 0644]
templates/classic/initial_data.py [new file with mode: 0644]
templates/classic/interfaces.py [deleted file]
templates/classic/schema.py [new file with mode: 0644]
templates/minimal/__init__.py [deleted file]
templates/minimal/config.py [deleted file]
templates/minimal/dbinit.py [deleted file]
templates/minimal/detectors/__init__.py [deleted file]
templates/minimal/detectors/userauditor.py
templates/minimal/extensions/README.txt [new file with mode: 0644]
templates/minimal/html/_generic.404.html [new file with mode: 0644]
templates/minimal/html/_generic.calendar.html [new file with mode: 0644]
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 [new file with mode: 0644]
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 [new file with mode: 0644]
templates/minimal/initial_data.py [new file with mode: 0644]
templates/minimal/interfaces.py [deleted file]
templates/minimal/schema.py [new file with mode: 0644]
test/README.txt
test/benchmark.py
test/db_test_base.py
test/mocknull.py [new file with mode: 0644]
test/test_actions.py
test/test_anydbm.py
test/test_bsddb.py [deleted file]
test/test_bsddb3.py [deleted file]
test/test_cgi.py
test/test_dates.py
test/test_hyperdbvals.py
test/test_indexer.py
test/test_mailgw.py
test/test_metakit.py [deleted file]
test/test_multipart.py
test/test_mysql.py
test/test_postgresql.py
test/test_rfc2822.py [new file with mode: 0644]
test/test_schema.py
test/test_security.py
test/test_sqlite.py
test/test_templating.py
test/test_textfmt.py [new file with mode: 0644]
test/test_tsearch2.py [new file with mode: 0644]
test/test_userauditor.py [new file with mode: 0644]
test/test_xmlrpc.py [new file with mode: 0644]
tools/fixroles.py
tools/load_tracker.py [new file with mode: 0755]
tools/migrate-queries.py

index aeaf23c760ec970f11213da4e0ba5121dcfc6f41..665a5b31310e5e34cd3d6f95c84c78774f8bbde6 100644 (file)
--- a/BUILD.txt
+++ b/BUILD.txt
@@ -2,58 +2,50 @@ Building Releases
 =================
 
 Roundup is currently a source-only release - it has no binary components. I
-want it to stay that way, too.
+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.
 
-This means that we only need to ever build source releases. This is done by
-running:
+Building and distributing a release of Roundup is done by running:
 
-1.  Make sure the unit tests run! "./run_tests"
-2.  Edit roundup/__init__.py and doc/announcement.txt to reflect the new
+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.
-3.  python setup.py clean --all
-4.  Edit setup.py to ensure that all information therein (version, contact
+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.
-5.  python setup.py sdist --manifest-only
-6.  Check the MANIFEST to make sure that any new files are included. If
+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.
-7.  python setup.py sdist
+9.  python setup.py sdist
     (if you find sdist a little verbose, add "--quiet" to the end of the
      command)
-8.  unpack the new dist file in /tmp then a) run_test.py and b) demo.py
+10. Unpack the new dist file in /tmp then a) run_test.py and b) demo.py
     with all available Python versions.
-9.  generate gpg signature with "gpg -a --detach-sign" and upload to
-    Sourceforge.
-10. PyPI registration
-11. tag the CVS for the release, eg. "cvs tag -R release-0-6-3"
+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@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
- python2.3 setup.py register
-
-
-Distributing Releases
-=====================
-
-Once a release is built, follow these steps:
-
-1. FTP the tar.gz from the dist directory to to the "incoming" directory on
-   "upload.sourceforge.net".
-2. Make a quick release at:
-    http://sourceforge.net/project/admin/qrs.php?package_id=&group_id=31577
-3. Add a news item at:
-    https://sourceforge.net/news/submit.php?group_id=31577
-   using the top of doc/announcement.txt
-4. Send doc/announcement.txt to python-announce@python.org
-5. Notify any other news services as appropriate...
-
-
-Author
-======
+ python setup.py bdist_rpm
+ python setup.py bdist_wininst
+ python setup.py register
+ python2.5 setup.py sdist upload --sign
 
-richard@users.sourceforge.net
 
index 262529cd856314a4934ee836035fccbb525c2dad..4d7db6f08f802fb26350a5f0a00923eb314c1945 100644 (file)
@@ -1,7 +1,806 @@
 This file contains the changes to the Roundup system over time. The entries
 are given with the most recent entry first.
 
-2004-??-?? 0.7.0
+2008-09-01 1.4.6
+Fixed:
+- Fix bug introduced in 1.4.5 in RDBMS full-text indexing
+- Make URL matching code less matchy
+- Try to clarify mail_domain config setting
+
+
+2008-08-19 1.4.5
+Feature:
+- Add use of username/password stored in ~/.netrc in mailgw (sf patch
+  #1912105)
+
+Fixed:
+- 'Make a Copy' failed with more than one person in nosy list (sf #1906147)
+- xml-rpc security checks and tests across all backends (sf #1907211)
+- Send a Precedence header in email so (well-written) autoresponders don't
+- Fix mailgw total failure bounce message generation (thanks Bradley Dean)
+- Fix for postgres 8.3 compatibility (and bug) (sf patch #2030479 and bug
+  #1959261)
+- Fix for translations (sf patch #2032526)
+- Fire reactors after file storage is all done (sf patch #2001243)
+- Allow negative ids other than -1 for item generation (sf patch #1982481)
+- Better German translation for retiring users (sf #1998701)
+- More improvements to German translation (sf #1919446)
+- Add filter() to XML-RPC interface (sf patch #1966456)
+- Fix IndexError when there are no messages to an issue (sf patch #1894249)
+- Prevent broken pipe errors in csv export (sf patch #1911449)
+- New session API and cleanup thanks anatoly t.
+- Make WSGI handler threadsafe (sf #1968027)
+- Improved URL matching RE (sf #2038858)
+- Allow binary file content submission via XML-RPC (sf #1995623)
+- Don't run old code on newer database (sf #1979556)
+- Fix HTML injection into page title
+- Fix indexer handling of indexed Link properties (sf #1936876)
+
+
+2008-03-01 1.4.4
+Fixed:
+- Security fixes (thanks Roland Meister)
+
+
+2008-02-27 1.4.3
+Fixed:
+- MySQL backend bug introduced in 1.4.2 (TEXT columns need a size when
+  being indexed)
+
+
+2008-02-08 1.4.2
+Feature:
+- New config option in mail section: ignore_alternatives allows to
+  ignore alternatives besides the text/plain part used for the content
+  of a message in multipart/alternative attachments.
+- Admin copy of error email from mailgw includes traceback (thanks Ulrik
+  Mikaelsson)
+- Messages created through the web are now given an in-reply-to header
+  when email out to nosy (thanks Martin v. Löwis)
+- Nosy messages now include more information about issues (all link
+  properties with a "name" attribute) (thanks Martin v. Löwis)
+
+Fixed:
+- Searching date range by supplying just a date as the filter spec
+- Handle no time.tzset under Windows (sf #1825643)
+- Fix race condition in file storage transaction commit (sf #1883580)
+- Make user utils JS work with firstname/lastname again (sf #1868323)
+- Fix ZRoundup to work with Zope 2.8.5 (sf #1806125)
+- Fix race condition for key properties in rdbms backends (sf #1876683)
+- Handle Reject in mailgw final set/create (sf #1826425)
+
+
+2007-11-09 1.4.1
+Fixed:
+- Removed some metakit references
+
+
+2007-11-04 1.4.0
+Feature:
+- Roundup has a new xmlrpc frontend that gives access to a tracker using
+  XMLRPC.
+- Dates can now be in the year-range 1-9999
+- The metakit backend has been removed
+- Add simple anti-spam recipe to docs
+- Allow customisation of regular expressions used in email parsing, thanks
+  Bruno Damour
+- Italian translation by Marco Ghidinelli
+- Multilinks take any iterable
+- config option: specify port and local hostname for SMTP connections
+- Tracker index templating (i.e. when roundup_server is serving multiple
+  trackers) (sf bug 1058020)
+- config option: Limit nosy attachments based on size (Philipp Gortan)
+- roundup_server supports SSL via pyopenssl
+- templatable 404 not found messages (sf bug 1403287)
+- Unauthorized email includes a link to the registration page for
+  the tracker
+- config options: control whether author info/email is included in email
+  sent by roundup
+- support for receiving OpenPGP MIME messages (signed or encrypted)
+
+Fixed:
+- Handling of unset Link search in RDBMS backend
+- Journal export of anydbm didn't correctly export previously empty values
+- Fix handling of defaults for date fields
+- Fix <form> name in user editing to allow multilink popups to work
+- Fix form handling of editing existing hyperdb items from a new item page.
+- Added new rdbms-indexes for full-text index which will speed up
+  reindexing.
+- Turning off indexing for content properties of FileClass instance
+  (e.g., "file" and "msg") now works for SQL backends.
+- Enabled over-riding of content-type in web interface (thanks
+  John Mitchell)
+- Validate user timezones to filter bad entries (sf bug 1738470)
+- Classic template allows searching for issues with no topic set
+  (sf bug 1610787)
+- xapian_indexer uses current API for stemming (Rick Benavidez)
+  (sf bug 1771414)
+- Ensure email addresses are unique (sf bug 1611787)
+- roundup_admin tracks uncommitted changes in interactive mode
+  for all backends (sf bug 1297014)
+- add template search path for easy_install (Marek Kubica)
+- don't spam the roundup admin on client shutdowns (Ulrik Mikaelsson)
+- respect umask on filestorage backends (Ulrik Mikaelsson) (sf bug 1744328)
+- cope with spam robots posting multiple instances of the same form
+- include the author of property-only changes in generated messages
+- fuller email validation in templates (sf feature 1216291)
+- cope with bad cookies from other apps on same domain (sf bug 1691708)
+- updated Spanish translation from Ramiro Morales
+- clean up query display of "Private to you items" (sf bug 1481394)
+- use local timezone for mail date header (sf bug 1658173)
+- allow CSV export of queries on selected issues (sf bug 1783492)
+- remove blobfiles on destroy (sf bug 1654132)
+- handle postgres exceptions during session cleanup (sf bug 1703116)
+- update Xapian indexer to use current API
+- handle export and import of old trackers that have data attached to
+  journal "create" events
+- fix a couple more old instances of "type" instead of "ENGINE" for mysql
+  backend
+- make LinkHTMLProperty handle non-existing keys (sf patch 1815895)
+
+
+2007-02-15 1.3.3
+Fixed:
+- If-Modified-Since handling was broken
+- Updated documentation for customising hard-coded searches in page.html
+- Updated Windows installation docs (thanks Bo Berglund)
+- Handle rounding of seconds generating invalid date values
+- Handle 8-bit untranslateable messages from database properties
+- Fix scripts/roundup-reminder date calculation (sf bug 1649979)
+- Improved due_date and timelog customisation docs (sf bug 1625124)
+
+
+2006-12-19 1.3.2
+Fixed:
+- relax rules for required fields in form_parser.py (sf bug 1599740)
+- documentation cleanup from Luke Ross (sf patch 1594860)
+- updated Spanish translation from Ramiro Morales (sf patch 1594718)
+- handle 8-bit untranslateable messages in tracker templates
+- handling of required for boolean False and numeric 0 (sf bug 1608200)
+- removed bogus args attr of ConfigurationError (sf bug 1608056)
+- implemented start_response in roundup.cgi (sf bug 1604304)
+- clarified windows service documentation (sf patch 1597713)
+- HTMLClass fixed to work with new item permissions check (sf bug 1602983)
+- support POP over SSL (sf patch 1597703)
+- clean up input field generation and quoting of values (sf bug 1615616)
+- allow use of roundup-server pidfile without forking (sf bug 1614753)
+- allow translation of status/priority menu options (sf bug 1613976)
+
+
+2006-11-11 1.3.1
+Fixed:
+- setup.py had broken reference to roundup.cgi (sf bug 1593573)
+- full-text search wasn't coping with multiple multilinks to the same class
+- unicode / sqlite 3 problem (sf bug 1589292)
+
+
+2006-11-09 1.3.0
+Feature:
+- WSGI support via roundup.cgi.wsgi_handler
+
+Fixed:
+- sqlite module detection was broken for python 2.5 compiled without sqlite
+  support
+- fixed support for pysqlite2 (version 2.1.0 is the minimum version
+  supported)
+- roundup-server called setuid when run by non-root user
+- fix sort/group direction checkbox in issue.index.html (sf bug 1593025)
+- fix error detection for non-EN locales of postgres (sf bug 1592249)
+- fix email change note rendering of multiline properties (sf patch 1575223)
+- fix sidebar search links (sf patch 1574467)
+- nicer "permission required" messages (sf patch 1558183)
+- fix unstable ordering of detectors (sf bug 1585378)
+
+
+2006-10-07 1.2.1
+Fixed:
+- E-mail subject line prefix delimiter configuration was being ignored.
+- Password confirm field in user editing.
+
+
+2006-10-04 1.2.0
+Feature:
+- supports Python 2.5, including the sqlite3 module
+- full timezone support (sf patch 1465296)
+- handle connection loss when responding to web requests
+- match incoming mail In-Reply-To against existing messages when no issue
+  id is specified in the Subject
+- added StringHTMLProperty wrapped() method to wrap long lines in issue
+  display
+- include the popcal in Date field editing and search fields by default
+- @required in forms may now specify properties of linked items (sf patch
+  1507093)
+- update for latest version of pysqlite (sf bug 1487098; patch 1534227)
+- update for latest version of psycopg2 (sf patch 1429391)
+- new "exporttables" command in roundup-admin (sf bug 1533791)
+- roundup-admin "export" may specify classes to exclude (sf bug 1533791)
+- sorting and grouping by multiple properties is now supported by the
+  backends *and* the classic template.
+- sorting, grouping, and searching by transitive properties (e.g.,
+  messages.author.supervisor) is now supported in all backends
+- added filter_sql to SQL backends which takes an arbitrary SQL statement
+  and returns a list of item ids
+
+
+Fixed:
+- Verbose option for import and export (sf bug 1505645)
+- -c option for roundup-mailgw won't accept parameter (sf bug 1505649)
+- '?' in rfc2822-encoded header isn't quoted (sf bug 1505663)
+- fix error message in form parser
+- updated ZRoundup for Zope 2.9 (sf patch 1511734)
+- fix timelog example in customisation doc to mention permissions
+- nicer listing of Superseder links (sf non-patch 1497767)
+- include roundup-server.ini.example (sf bug 1493859)
+- dumb bug in cgi templating utils (sf bug 1490176)
+- handle unicode in query names (sf bug 1495702)
+- fix error during mailgw bouncing message (sf bug 1413501)
+- hyperdb handling of empty raw values for Multilink and Password (sf bug
+  1507814)
+- don't int() ids (sf bug 1512939)
+- fix importing into anydbm backend (sf bug 1512939)
+- fix help message for roundup-admin install (sf bug 1494990)
+- removed traceback with OTK is used multiple times (sf bug 1240539)
+- metakit backend was indexing FileClass content even when asked not to
+- anydbm backend will finally sort numerically by ID
+- problem with string sorting in anydbm backend fixed: If a string was
+  fully numeric it was sorted as a number
+- Multilink-sorting now sorts by orderprop not by ID and works for all
+  backends
+- Bug with name-collisions in sorted classes when sorting by Link
+  properties in metakit backend fixed
+- Postgres backend allows transaction collisions to be ignored when
+  committing cleanup in the sessions database
+- translate titles of "show all" and "unassigned" issue lists
+  in classic template (sf patch 1424576)
+- "as" is a keyword in Python 2.6
+- "from __future__" statments need to be first line of file in Python 2.6
+- better conflict retry in postgresql backend (sf bug 1552809)
+- fix time log example (sf bug 1554630)
+
+
+2006-04-27 1.1.2
+Feature:
+- server-ctl script uses server configuration file (sf bug 1443805)
+- mail user interface translated (sf patch 1462491)
+
+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
@@ -13,6 +812,7 @@ Feature:
 - 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
@@ -31,6 +831,7 @@ Fixed:
 - 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
@@ -107,9 +908,9 @@ Fixed:
 - 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 
+- 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 
+- 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)
@@ -133,14 +934,21 @@ Cleanup:
 - 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 
+  * 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-??-?? 0.6.9
+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
@@ -284,7 +1092,7 @@ Fixed:
 - 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 
+- 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
 
@@ -319,7 +1127,7 @@ Feature:
   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 
+- 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)
@@ -377,7 +1185,7 @@ Feature:
 
 Fixed:
 - applied unicode patch. All data is stored in utf-8. Incoming messages
-  converted from any encoding to utf-8, outgoing messages are encoded 
+  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)
@@ -460,7 +1268,7 @@ Fixed:
 - 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 
+- fixed form elements names in search form to allow grouping and sorting
   on "creation" field
 - display of saved queries is now performed correctly
 
@@ -650,7 +1458,7 @@ Feature:
 -  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 
+-  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
@@ -684,7 +1492,7 @@ Feature:
      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 
+   - 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
@@ -762,7 +1570,7 @@ Fixed:
    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 
+   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
@@ -775,7 +1583,7 @@ Fixed:
 -  #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/ 
+-  #558867 ] ZRoundup redirect /instance requests to /instance/
 -  #569415 ] {version}
 -  #569178 ] type error
    was fixed as part of the general cleanup of reactors
@@ -839,13 +1647,13 @@ Fixed:
 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 
+-  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 
+-  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)
@@ -952,7 +1760,7 @@ Fixed:
 -  added tests for mailgw
 
 
-2001-11-23 - 0.3.0 
+2001-11-23 - 0.3.0
 Feature:
 -  #467129 ] Lossage when username=e-mail-address
 -  #473123 ] Change message generation for author
@@ -1260,7 +2068,7 @@ Features:
 -  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 
+-  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.
index 84c6a8e306939e63783647f03e63d98efefb26b4..1f0818979a690043de9cda881f93127cb5baf039 100644 (file)
--- a/ChangeLog
+++ b/ChangeLog
@@ -1,3 +1,8 @@
+2006-11-22  Stefan Seefeld  <stefan@codesourcery.com>
+
+       * roundup/cgi/form_parser.py: Allow required fields to be ommitted
+       if user doesn't have edit permission and the value is already set.
+
 2001-08-03 11:54  richard
 
        * BUILD.txt, CHANGES.txt, README.txt, setup.py,
 
        * 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: not supported by cvs2svn $ to all python files.
+       Added CVS keywords $Id: ChangeLog,v 1.8 2006-11-23 00:44:48 stefan Exp $ and $Log: not supported by cvs2svn $
+       Added CVS keywords $Id: ChangeLog,v 1.8 2006-11-23 00:44:48 stefan Exp $ and Revision 1.7  2001/08/03 02:12:07  anthonybaxter
+       Added CVS keywords $Id: ChangeLog,v 1.8 2006-11-23 00:44:48 stefan Exp $ and regenerated on Fri Aug  3 12:12:00 EST 2001
+       Added CVS keywords $Id: ChangeLog,v 1.8 2006-11-23 00:44:48 stefan Exp $ and to all python files.
 
 2001-07-19 15:46  anthonybaxter
 
diff --git a/I18N_PROGRESS.txt b/I18N_PROGRESS.txt
deleted file mode 100644 (file)
index 2ee110f..0000000
+++ /dev/null
@@ -1,93 +0,0 @@
-This list has been generated using the MANIFEST file. We should be able to
-write a simple script to compare the two and make sure that all MANIFEST
-files appear in here.
-
-To generate a messages.pot file, use this command:
-
-    python tools/pygettext.py roundup roundup-* cgi-bin/roundup.cgi
-
-"messages.pot" then contains a positive list of files using _(), which can
-be extracted by;
-
-    grep "#: " messages.pot | tr ":#0123456789 " "\012" | sort | uniq
-
-Of course, this does not check whether a file is fully translated, only
-whether there is at least one use of "_()".
-
-
-THESE FILES DO NOT USE _()
-==========================
-roundup/hyperdb.py
-roundup/i18n.py
-roundup/init.py
-roundup/install_util.py
-roundup/instance.py
-roundup/mailgw.py
-roundup/password.py
-roundup/roundupdb.py
-roundup/templatebuilder.py
-roundup/backends/__init__.py
-roundup/backends/back_anydbm.py
-roundup/backends/back_bsddb.py
-roundup/backends/back_bsddb3.py
-roundup/templates/__init__.py
-roundup/templates/classic/__init__.py
-roundup/templates/classic/dbinit.py
-roundup/templates/classic/htmlbase.py
-roundup/templates/classic/config.py
-roundup/templates/classic/interfaces.py
-roundup/templates/classic/detectors/__init__.py
-roundup/templates/classic/detectors/nosyreaction.py
-
-
-THESE FILES DO USE _()
-======================
-roundup-admin
-roundup-mailgw
-roundup-server
-cgi-bin/roundup.cgi
-roundup/__init__.py
-roundup/admin.py
-roundup/date.py
-roundup/cgi/cgitb.py
-
-
-WE DON'T CARE ABOUT THESE FILES
-===============================
-BUILD.txt
-CHANGES.txt
-INSTALL.txt
-README.txt
-setup.py
-doc/implementation.txt
-doc/index.html
-doc/overview.html
-doc/spec.html
-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
-roundup/templates/classic/html/file.index
-roundup/templates/classic/html/file.newitem
-roundup/templates/classic/html/issue.filter
-roundup/templates/classic/html/issue.index
-roundup/templates/classic/html/issue.item
-roundup/templates/classic/html/msg.index
-roundup/templates/classic/html/msg.item
-roundup/templates/classic/html/style.css
-roundup/templates/classic/html/user.index
-roundup/templates/classic/html/user.item
-test/README.txt
-test/__init__.py
-test/test_dates.py
-test/test_db.py
-test/test_init.py
-test/test_mailsplit.py
-test/test_multipart.py
-test/test_schema.py
-test/test_templating.py
-test/unittest.py
-
index 4ae1a14f655bf92592937a573f1c33116def96d6..960a570c1d3e401abd3ee158e1d97ebfc5a4b1fa 100644 (file)
@@ -2,13 +2,12 @@ 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 doc *.html *.png *.txt *.css *.1 *.example
 recursive-include detectors *.py
 recursive-include templates *.* home* page*
-global-exclude .cvsignore *.pyc *.pyo
-include run_tests.py *.txt demo.py MANIFEST.in
+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
index 64577860299cfb596ba0aa5434528b055ea0b9f9..f0b66894804a39a863643facc6b7fdf1cb65bfc8 100644 (file)
@@ -18,6 +18,10 @@ To start anew (a fresh demo instance)::
 
    python demo.py nuke
 
+Run demo.py from the *source* directory; don't try to run demo.py from
+the *installed* directory, it will *break*.
+
+
 Installation
 ============
 For installation instructions, please see installation.txt in the "doc"
@@ -27,11 +31,14 @@ 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.
+The *.txt files in the "doc" directory are written in reStructedText. If
+you have rst2html installed (part of the docutils suite) you can convert
+these to HTML by running "make html" in the "doc" directory.
 
 
 License
diff --git a/TODO.txt b/TODO.txt
deleted file mode 100644 (file)
index 29af698..0000000
--- a/TODO.txt
+++ /dev/null
@@ -1,8 +0,0 @@
-This file contains items that need doing before the next release:
-
-. make Intervals store timestamps, not strings
-
-
-Optionally:
-- have rdbms backends look up the journal for actor if it's not set
-- migrate to numeric ID values (fixes bug 817217)
index bc012945831178a2f880f84dcb5d9f25dd332d45..9674437ed35a6ce4b11275b67c7b88c548c1e610 100755 (executable)
 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 # 
-# $Id: roundup.cgi,v 1.37 2003-11-03 23:37:06 richard Exp $
+# $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
+import sys, time
 
 #
 ##  Configuration
@@ -42,8 +42,9 @@ import sys
 # 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). 
 
-# ROUNDUP_DEBUG is a debug level, currently only 0 (OFF) and 1 (ON) are
-# used in the code. Higher numbers means more debugging output. 
+# 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 = {
@@ -158,16 +159,19 @@ def main(out, err):
         else:
             tracker_home = TRACKER_HOMES[tracker]
             tracker = roundup.instance.open(tracker_home)
-            from roundup.cgi.client import Unauthorised, NotFound
-            client = tracker.Client(tracker, request, os.environ)
+            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 Unauthorised:
+            except roundup.cgi.client.Unauthorised:
                 request.send_response(403)
                 request.send_header('Content-Type', 'text/html')
                 request.end_headers()
                 out.write('Unauthorised')
-            except NotFound:
+            except roundup.cgi.client.NotFound:
                 request.send_response(404)
                 request.send_header('Content-Type', 'text/html')
                 request.end_headers()
@@ -208,7 +212,16 @@ except SystemExit:
 except:
     sys.stdout, sys.stderr = out, err
     out.write('Content-Type: text/html\n\n')
-    cgitb.handler()
+    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()
diff --git a/demo.py b/demo.py
index 7d7f9479adb9f05e98adf162ae3334b15a169b6b..b911541c3c48b20d0af5817f88195549ccd290c6 100644 (file)
--- a/demo.py
+++ b/demo.py
@@ -1,36 +1,51 @@
 #! /usr/bin/env python
 #
 # Copyright (c) 2003 Richard Jones (richard@mechanicalcat.net)
-# 
-# $Id: demo.py,v 1.10 2004-03-31 23:07:51 richard Exp $
+#
+# $Id: demo.py,v 1.26 2007-08-28 22:37:45 jpend Exp $
 
-import sys, os, string, re, urlparse
-import shutil, socket, errno, BaseHTTPServer
+import errno
+import os
+import socket
+import sys
+import urlparse
 from glob import glob
 
-def install_demo(home, backend):
-    # create the instance
-    if os.path.exists(home):
-        shutil.rmtree(home)
+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 = getattr(backends, backend)
-    if backend == 'mysql':
-        class config:
-            MYSQL_DBHOST = 'localhost'
-            MYSQL_DBUSER = 'rounduptest'
-            MYSQL_DBPASSWORD = 'rounduptest'
-            MYSQL_DBNAME = 'rounduptest'
-            DATABASE = 'home'
+    module = backends.get_backend(backend)
+    if module.db_exists(config):
         module.db_nuke(config)
-    elif backend == 'postgresql':
-        class config:
-            POSTGRESQL_DATABASE = {'database': 'rounduptest'}
-            DATABASE = 'home'
-        module.db_nuke(config, 1)
 
-    init.install(home, os.path.join('templates', 'classic'))
+    init.install(home, template)
     # don't have email flying around
     os.remove(os.path.join(home, 'detectors', 'nosyreaction.py'))
     try:
@@ -41,7 +56,7 @@ def install_demo(home, backend):
     init.write_select_db(home, backend)
 
     # figure basic params for server
-    hostname = socket.gethostname()
+    hostname = 'localhost'
     # pick a fairly odd, random port
     port = 8917
     while 1:
@@ -59,71 +74,63 @@ def install_demo(home, backend):
             s.close()
             print 'already in use.'
             port += 100
-    url = 'http://%s:%s/demo/'%(hostname, port)
+    config['TRACKER_WEB'] = 'http://%s:%s/demo/'%(hostname, port)
 
     # write the config
-    f = open(os.path.join(home, 'config.py'), 'r')
-    s = f.read().replace('http://tracker.example/cgi-bin/roundup.cgi/bugs/',
-        url)
-    f.close()
-    # DB connection stuff for mysql and postgresql
-    s = s + """
-MYSQL_DBHOST = 'localhost'
-MYSQL_DBUSER = 'rounduptest'
-MYSQL_DBPASSWORD = 'rounduptest'
-MYSQL_DBNAME = 'rounduptest'
-MYSQL_DATABASE = (MYSQL_DBHOST, MYSQL_DBUSER, MYSQL_DBPASSWORD, MYSQL_DBNAME)
-POSTGRESQL_DATABASE = {'database': 'rounduptest'}
-"""
-    f = open(os.path.join(home, 'config.py'), 'w')
-    f.write(s)
-    f.close()
-
-    # initialise the database
-    init.initialise(home, 'admin')
+    config['INSTANT_REGISTRATION'] = 1
+    config.save(os.path.join(home, config.INI_FILE))
 
-    # add the "demo" user
+    # 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():
-    ''' 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')
-    backend = 'anydbm'
-    if not os.path.exists(home) or sys.argv[-1] == 'nuke':
-        if len(sys.argv) > 2:
-            backend = sys.argv[1]
-        install_demo(home, backend)
-
-    f = open(os.path.join(home, 'config.py'), 'r')
-    url = re.search(r'^TRACKER_WEB\s*=\s*[\'"](http.+/)[\'"]$', f.read(),
-        re.M|re.I).group(1)
-    f.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)
-
-    # ok, so start up the server
-    from roundup.scripts import roundup_server
-    roundup_server.RoundupRequestHandler.TRACKER_HOMES = {'demo': home}
-
     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
+3. Re-start the server by running "roundup-demo" again.
+4. Re-initialise the server by running "roundup-demo nuke".
+
+Demo tracker is set up to be accessed by localhost browser.  If you
+run demo on a server host, please stop the demo, open file
+"demo/config.ini" with your editor, change the host name in the "web"
+option in section "[tracker]", save the file, then re-run the demo
+program.
+
+''' % url
 
-    sys.argv = sys.argv[:1]
-    roundup_server.run(port, success_message)
+    # 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__':
-    run_demo()
+    demo_main()
 
-# vim: set filetype=python ts=4 sw=4 et si
+# vim: set filetype=python sts=4 sw=4 et si :
index 2f1c290ea333ca2281bc447352219993826420d2..e4af8bca033935d96eae22a0eb78762c445b2343 100644 (file)
@@ -28,7 +28,7 @@ def eml_to_mht(db, cl, nodeid, newvalues):
         name extension.
 
     So... we do that. :)'''
-    if newalues.get('type', '').lower() == "message/rfc822":
+    if newvalues.get('type', '').lower() == "message/rfc822":
         if not newvalues.has_key('name'):
             newvalues['name'] = 'email.mht'
             return
index 0f9a7cfc810c30728e3035a383096e841bc201e4..f16764b9664d85009a316295a5b26786a504e8cf 100644 (file)
@@ -17,3 +17,5 @@ mysql.html
 postgresql.html
 tracker_templates.html
 whatsnew-0.7.html
+whatsnew-0.8.html
+*.ht
index 62a7dbede131e8d4afe82e3816dded46e7cc72b0..dbdbeb5de4dcf3b411929317d1421175e9b1974e 100644 (file)
@@ -2,7 +2,7 @@
 Roundup FAQ
 ===========
 
-:Version: $Revision: 1.16 $
+:Version: $Revision: 1.23 $
 
 .. contents::
 
@@ -13,7 +13,7 @@ Installation
 Living without a mailserver
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-Remove the nosy reactor, means delete the tracker file
+Remove the nosy reactor - delete the tracker file
 ``detectors/nosyreactor.py`` from your tracker home.
 
 
@@ -23,24 +23,27 @@ 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.
+The solution is to use the built in server (or possibly the mod_python
+or WSGI support).
 
 To make Roundup more seamless with your website, you may place the built
-in server behind apache and link it into your web tree
+in server behind apache and link it into your web tree (see below).
 
 
 How do I put Roundup behind Apache
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-We have a project (foo) running on ``foohost.com:8888``.
-We want ``http://foohost.com/FooIssues`` to use the roundup server, so we 
-set that up on port 8888 on ``foohost.com`` with the ``config.py`` line::
+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://foohost.com/FooIssues/'
+  [tracker]
+  ...
+  web = 'http://tracker.example/issues/'
 
 We have a "foo_issues" tracker and we run the server with::
 
-  roundup-server -p 8888 foo_issues=/home/roundup/trackers/foo_issues 
+  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::
@@ -53,27 +56,50 @@ and::
 
 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://foohost.com:8888/$1   [P,L]
+  RewriteRule ^/roundup$  roundup/    [R]
+  RewriteRule ^/roundup/(.*)$ http://tracker.example:8080/$1   [P,L]
   
   # Handle Foo Issues
-  RewriteRule ^/FooIssues$  FooIssues/    [R]
-  RewriteRule ^/FooIssues/(.*)$ http://foohost.com:8888/foo_issues/$1 [P,L]
+  RewriteRule ^/issues$  issues/    [R]
+  RewriteRule ^/issues/(.*)$ http://tracker.example:8080/issues/$1 [P,L]
   
   </IfModule>
 
-Then restart Apache. Now Apache will proxy the request on to the
-roundup-server.
 
-You need to add the last 3 RewriteRule lines for each tracker that you have.
+How do I run Roundup through SSL (HTTPS)?
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-You can now firewall off port 8888 from the rest of the world.
+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
@@ -100,9 +126,9 @@ 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/current/contents
+   http://www.zope.org/Documentation/Books/ZopeBook/2_6Edition/ 
 
-specifically chapter 5 "Using Zope Page Templates" and chapter 9 "Advanced
+specifically chapter 10 "Using Zope Page Templates" and chapter 14 "Advanced
 Page Templates".
 
 
@@ -156,9 +182,27 @@ 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.py is set
-to the URL of the tracker.
+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.
 
 -----------------
 
index e17fbd9b8f8a4806f83bad82f2cceede3af560e3..a5ce16e84d5329e5d0f249e1c93dc2bec78d9f69 100644 (file)
@@ -1,17 +1,28 @@
-PYTHON = /usr/bin/python2
 STXTOHTML = rst2html
+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 whatsnew-0.7.txt
+       postgresql.txt tracker_templates.txt xmlrpc.txt
 
 COMPILED := $(SOURCE:.txt=.html)
+WEBHT := $(SOURCE:.txt=.ht)
 
-all: ${COMPILED}
+all: html ht
+html: ${COMPILED}
+ht: ${WEBHT}
+
+website: ${WEBHT}
+       cp *.ht ${WEBDIR}
+       cp -r images ${WEBDIR}
 
 %.html: %.txt
        ${STXTOHTML} --report=warning -d $< $@
 
+%.ht: %.txt
+       ${STXTOHT} --report=warning -d $< $@
+
 clean:
        rm -f ${COMPILED}
index fe02582a2514f6cd8b0598d4879312391fa214e3..ebf4c5e840e55c7a234aed137244f2d4040391be 100644 (file)
@@ -2,7 +2,7 @@
 Administration Guide
 ====================
 
-:Version: $Revision: 1.5 $
+:Version: $Revision: 1.28 $
 
 .. contents::
 
@@ -36,8 +36,98 @@ There's two "installations" that we talk about when using Roundup:
    "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 DATABASE variable in
-   config.py.
+   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 =
+    ;template =
+    ;ssl = no
+    ;pem =
+
+    [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.
+**template**
+  Specifies a template used for displaying the tracker index when
+  multiple trackers are being used. The variable "trackers" is available
+  to the template and is a dict of all configured trackers.
+**ssl**
+  Enables the use of SSL to secure the connection to the roundup-server.
+  If you enable this, ensure that your tracker's config.ini specifies
+  an *https* URL.
+**pem**
+  If specified, the SSL PEM file containing the private key and certificate.
+  If not specified, roundup will generate a temporary, self-signed certificate
+  for use.
+**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
@@ -64,7 +154,7 @@ Roundup identifies users in a number of ways:
 
 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 ``dbinit.py`` module:
+tracker's ``schema.py`` module:
 
 Web Registration
   If granted to the Anonymous Role, then anonymous users will be able to
@@ -76,22 +166,32 @@ Email Registration
 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`_ 
+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.
+The roundup-admin import and export commands are **not** recommended for
+performing backup.
+
+Optionally stop the web and email frontends and to copy the contents of the
+tracker home directory to some other place using standard backup tools.
+This means using
+*pg_dump* to take a snapshot of your Postgres backend database, for example.
+A simple copy of the tracker home (and files storage area if you've configured
+it to be elsewhere) will then complete the backup.
 
 
 Software Upgrade
@@ -100,34 +200,53 @@ 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
-2. copy your tracker home to a new directory
-3. follow the steps in the upgrading documentation for the new version of
-   the software
-4. test each of the admin tool, web interface and mail gateway using the new
-   version of the software
-5. stop the production web and email frontends
-6. perform the upgrade steps on the existing tracker directory
-7. upgrade the software
-8. restart your tracker
+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. Install the new version of the software::
+
+    python setup.py install
+
+6. Follow the steps in the `upgrading documentation`_ for the new version of
+   the software in the copied.
+
+   Usually you will be asked to run `roundup_admin migrate` on your tracker
+   before you allow users to start accessing the tracker.
+
+   It's safe to run this even if it's not required, so just get into the
+   habit.
+7. 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. change the backend used in the tracker home ``select_db.py`` file
-5. delete the "db" directory from the new directory
-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
+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. If non-interactively::
+     
+     roundup-admin -i <tracker home> import <tracker export dir>
+
+   If interactively, enter 'commit' before exitting.
+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
@@ -154,6 +273,54 @@ moved using the above steps) then you'll need to:
 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
 ===================
 
@@ -168,18 +335,28 @@ 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.
+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:
 
-TODO: info how to bring up the services panel.
+**install the service**
+  ``roundup-server -C /path/to/my/roundup-server.ini -c install``
+**start the service**
+  ``roundup-server -c start``
+**stop the service**
+  ``roundup-server -c stop``
 
-TODO: how to start the server in "service" mode.
+To bring up the services panel:
 
-On windows, use::
+Windows 2000 and later
+  Start/Control Panel/Administrative Tools/Services
+Windows NT4
+  Start/Control Panel/Services
 
-   roundup-server -c stop
-
-to stop the running server.
+You will need a server configuration file (as described in
+`Configuring roundup-server`_) for specifying tracker homes
+and other roundup-server configuration. Specify the name of
+this file using the ``-C`` switch when installing the service.
 
 Running the Mail Gateway Script
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -199,4 +376,4 @@ Back to `Table of Contents`_
 
 .. _`Table of Contents`: index.html
 .. _`customisation documentation`: customizing.html
-
+.. _`upgrading documentation`: upgrading.html
index 0b4f0527feb3e9a5b36d4a20ffe100b025051abf..148ccdd72f5f6cdcf0799c61f28337ad06d9d393 100644 (file)
@@ -1,52 +1,23 @@
-This is the second beta release of Roundup version 0.7. It fixes some bugs
-in the previous beta release:
-
-- 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
+I'm proud to release version 1.4.6 of Roundup.
 
-If you're upgrading from an older version of Roundup you *must* follow
-the "Software Upgrade" guidelines given in the maintenance documentation.
-
-No, really, this is a BETA and if you don't follow the upgrading steps,
-particularly the bit about BACKING UP YOUR DATA, I'm NOT GOING TO BE HELD
-RESPONSIBLE. This release is NOT FOR GENERAL USE.
-
-I would *greatly* appreciate people giving this release a whirl with a
-copy of their existing setup. It's only through real-world testing of
-beta releases that we can ensure that older trackers will be OK.
+1.4.6 is a bugfix release:
 
-Version 0.7 introduces far too many features to list here. I've put
-together a What's New page:
+- Fix bug introduced in 1.4.5 in RDBMS full-text indexing
+- Make URL matching code less matchy
 
-  http://roundup.sourceforge.net/doc-0.7/whatsnew-0.7.html
-
-Some highlights:
-
-- added postgresql backend
-- RDBMS backends have no external locking requirements
-- new "actor" automatic property (user who caused the last "activity")
-- RDBMS backends have data typed columns and indexes on several columns
-- we support confirming registration by replying to the email
-- all HTML templating methods now automatically check for permissions,
-  greatly simplifying templates
+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.1.3 or later for correct operation.
+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
+    roundup-demo
 
+Release info and download page:
+     http://cheeseshop.python.org/pypi/roundup
 Source and documentation is available at the website:
      http://roundup.sourceforge.net/
-Release Info (via download page):
-     http://sourceforge.net/projects/roundup
 Mailing lists - the place to ask questions:
      http://sourceforge.net/mail/?group_id=31577
 
@@ -71,11 +42,11 @@ Roundup manages a number of issues (with flexible properties such as
 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.1+ installation. It
+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 seven database back-ends (anydbm, bsddb, bsddb3,
-sqlite, metakit, mysql and postgresql). 
+a minimal skeleton) and four database back-ends (anydbm, sqlite, mysql
+and postgresql).
 
index abc945c807334dc8e712d4c2d306f6684603909d..5273e80fe048fdb173b5e9c2409ac4fb4297e7a4 100644 (file)
@@ -2,7 +2,7 @@
 Customising Roundup
 ===================
 
-:Version: $Revision: 1.134 $
+:Version: $Revision: 1.223 $
 
 .. This document borrows from the ZopeBook section on ZPT. The original is at:
    http://www.zope.org/Documentation/Books/ZopeBook/current/ZPT.stx
@@ -18,7 +18,7 @@ Before you get too far, it's probably worth having a quick read of the Roundup
 
 Customisation of Roundup can take one of six forms:
 
-1. `tracker configuration`_ file changes
+1. `tracker configuration`_ changes
 2. database, or `tracker schema`_ changes
 3. "definition" class `database content`_ changes
 4. behavioural changes, through detectors_
@@ -39,290 +39,448 @@ Trackers have the following structure:
 =================== ========================================================
 Tracker File        Description
 =================== ========================================================
-config.py           Holds the basic `tracker configuration`_                 
-dbinit.py           Holds the `tracker schema`_                              
-interfaces.py       Defines the Web and E-Mail interfaces for the tracker    
-select_db.py        Selects the database back-end for the tracker            
+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         
+lib/                optional common imports for detectors and extensions
 =================== ======================================================== 
 
+
 Tracker Configuration
 =====================
 
-The ``config.py`` located in your tracker home contains the basic
+The ``config.ini`` located in your tracker home contains the basic
 configuration for the web and e-mail components of roundup's interfaces.
-As the name suggests, this file is a Python module. This means that any
-valid python expression may be used in the file. Mostly though, you'll
-be setting the configuration variables to string values. Python string
-values must be quoted with either single or double quotes::
-
-   'this is a string'
-   "this is also a string - use it when the value has 'single quotes'"
-   this is not a string - it's not quoted
-
-Python strings may use formatting that's almost identical to C string
-formatting. The ``%`` operator is used to perform the formatting, like
-so::
-
-    'roundup-admin@%s'%MAIL_DOMAIN
-
-this will create a string ``'roundup-admin@tracker.domain.example'`` if
-MAIL_DOMAIN is set to ``'tracker.domain.example'``.
-
-You'll also note some values are set to::
-
-   os.path.join(TRACKER_HOME, 'db')
-
-or similar. This creates a new string which holds the path to the
-``'db'`` directory in the TRACKER_HOME directory. This is just a
-convenience so if the TRACKER_HOME changes you don't have to edit
-multiple valoues.
 
-The configuration variables available are:
-
-**TRACKER_HOME** - ``os.path.split(__file__)[0]``
- The tracker home directory. The above default code will automatically
- determine the tracker home for you, so you can just leave it alone.
+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.
+
+ static_files -- default *blank*
+  Path to directory holding additional static files available via Web
+  UI.  This directory may contain sitewide images, CSS stylesheets etc.
+  and is searched for these files prior to the TEMPLATES directory
+  specified above.  If this option is not set, all static files are
+  taken from the 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@tracker.example>``
+  the EMAIL_FROM_TAG goes inside the "Foo Bar" quotes like so:
+  ``"Foo Bar EMAIL_FROM_TAG" <issue_tracker@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, ...``).
+
+ umask -- ``02``
+  Defines the file creation mode mask.
+
+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.
+
+ language -- default *blank*
+  Default locale name for this tracker. If this option is not set, the
+  language is determined by the environment variable LANGUAGE, LC_ALL,
+  LC_MESSAGES, or LANG, in that order of preference.
+
+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.
+
+ read_default_file -- ``~/.my.cnf``
+  Name of the MySQL defaults file. Only used in MySQL connections.
+
+ read_default_group -- ``roundup``
+  Name of the group to use in the MySQL defaults file. Only used in
+  MySQL connections.
+
+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.
+
+ port -- default *25*
+  SMTP port on mail host.
+  Set this if your mail host runs on a different port.
+
+ local_hostname -- default *blank*
+  The fully qualified domain name (FQDN) to use during SMTP sessions. If left
+  blank, the underlying SMTP library will attempt to detect your FQDN. If your
+  mail host requires something specific, specify the FQDN to use.
+
+ 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.
+
+ add_authorinfo -- ``yes``
+  Add a line with author information at top of all messages send by
+  roundup.
+
+ add_authoremail -- ``yes``
+  Add the mail address of the author to the author information at the
+  top of all messages.  If this is false but add_authorinfo is true,
+  only the name of the actor is added which protects the mail address
+  of the actor from being exposed at mail archives, etc.
+
+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.
+
+ language -- default *blank*
+  Default locale name for the tracker mail gateway.  If this option is
+  not set, mail gateway will use the language of the tracker instance.
+
+ 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.
+
+ refwd_re -- ``(\s*\W?\s*(fw|fwd|re|aw|sv|ang)\W)+``
+  Regular expression matching a single reply or forward prefix
+  prepended by the mailer. This is explicitly stripped from the
+  subject during parsing.  Value is Python Regular Expression
+  (UTF8-encoded).
+
+ origmsg_re -- `` ^[>|\s]*-----\s?Original Message\s?-----$``
+  Regular expression matching start of an original message if quoted
+  the in body.  Value is Python Regular Expression (UTF8-encoded).
+
+ sign_re -- ``^[>|\s]*-- ?$``
+  Regular expression matching the start of a signature in the message
+  body.  Value is Python Regular Expression (UTF8-encoded).
+
+ eol_re -- ``[\r\n]+``
+  Regular expression matching end of line.  Value is Python Regular
+  Expression (UTF8-encoded).
+
+ blankline_re -- ``[\r\n]+\s*[\r\n]+``
+  Regular expression matching a blank line.  Value is Python Regular
+  Expression (UTF8-encoded).
+
+Section **pgp**
+ OpenPGP mail processing options
+
+ enable -- ``no``
+  Enable PGP processing. Requires pyme.
+
+ roles -- default *blank*
+  If specified, a comma-separated list of roles to perform PGP
+  processing on. If not specified, it happens for all users.
+
+ homedir -- default *blank*
+  Location of PGP directory. Defaults to $HOME/.gnupg if not
+  specified.
+
+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``
 
-**MAILHOST** - ``'localhost'``
- The SMTP mail host that roundup will use to send e-mail.
+ 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.
 
-**MAILUSER** - ``()``
- If your SMTP mail host requires a username and password for access, then
- specify them here. eg. ``MAILUSER = ('username', 'password')``
+ max_attachment_size -- ``2147483647``
+  Attachments larger than the given number of bytes won't be attached
+  to nosy mails. They will be replaced by a link to the tracker's
+  download page for the file.
 
-**MAILHOST_TLS** - ``'no'``
- If your SMTP mail host provides or requires TLS (Transport Layer
- Security) then set ``MAILHOST_TLS = 'yes'``
 
-**MAILHOST_TLS_KEYFILE** - ``''``
- If you're using TLS, you may also set MAILHOST_TLS_KEYFILE to the name of
- a PEM formatted file that contains your private key.
-
-**MAILHOST_TLS_CERTFILE** - ``''``
- If you're using TLS and have specified a MAILHOST_TLS_KEYFILE, you may
- also set MAILHOST_TLS_CERTFILE to the name of a PEM formatted certificate
- chain file.
-
-**MAIL_DOMAIN** - ``'tracker.domain.example'``
- The domain name used for email addresses.
-
-**DATABASE** - ``os.path.join(TRACKER_HOME, 'db')``
- This is the directory that the database is going to be stored in. By default
- it is in the tracker home.
-
-**TEMPLATES** - ``os.path.join(TRACKER_HOME, 'html')``
- This is the directory that the HTML templates reside in. By default they are
- in the tracker home.
+You may generate a new default config file using the ``roundup-admin
+genconfig`` command.
 
-**TRACKER_NAME** - ``'Roundup issue tracker'``
- A descriptive name for your roundup tracker. This is sent out in e-mails and
- appears in the heading of CGI pages.
+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_EMAIL** - ``'issue_tracker@%s'%MAIL_DOMAIN``
- The email address that e-mail sent to roundup should go to. Think of it as the
- tracker's personal e-mail address.
+Extending the configuration file
+--------------------------------
 
-**TRACKER_WEB** - ``'http://tracker.example/cgi-bin/roundup.cgi/bugs/'``
- 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.
+You can't add new variables to the config.ini file in the tracker home but
+you can add two new config.ini files:
 
-**ADMIN_EMAIL** - ``'roundup-admin@%s'%MAIL_DOMAIN``
- The email address that roundup will complain to if it runs into trouble.
+- a config.ini in the ``extensions`` directory will be loaded and attached
+  to the config variable as "ext".
+- a config.ini in the ``detectors`` directory will be loaded and attached
+  to the config variable as "detectors".
 
-**EMAIL_FROM_TAG** - ``''``
- 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::
+For example, the following in ``detectors/config.ini``::
 
-    "Foo Bar" <issue_tracker@tracker.example>
+    [main]
+    qa_recipients = email@example.com
 
- The EMAIL_FROM_TAG goes inside the "Foo Bar" quotes like so::
+is accessible as::
 
-    "Foo Bar EMAIL_FROM_TAG" <issue_tracker@tracker.example>
+    db.config.detectors['QA_RECIPIENTS']
 
-**ERROR_MESSAGES_TO** - ``'user'``, ``'dispatcher'`` or ``'both'``
- Sends error messages to the dispatcher, user, or both. It will use the
- ``DISPATCHER_EMAIL`` address if set, otherwise it'll use the
- ``ADMIN_EMAIL`` address.
+Note that the name grouping applied to the main configuration file is
+applied to the extension config files, so if you instead have::
 
-**DISPATCHER_EMAIL** - ``''``
-  The email address that Roundup will issue all error messages to. This is
-  also useful if you want to switch your 'new message' notification to
-  a central user. 
+    [qa]
+    recipients = email@example.com
 
-**MESSAGES_TO_AUTHOR** - ``'new'``, ``'yes'`` or``'no'``
- Send nosy messages to the author of the message?
- If 'new' is used, then the author will only be sent the message when the
- message creates a new issue. If 'yes' then the author will always be sent
- a copy of the message they wrote.
+then the above ``db.config.detectors['QA_RECIPIENTS']`` will still work.
 
-**ADD_AUTHOR_TO_NOSY** - ``'new'``, ``'yes'`` or ``'no'``
- 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** - ``'new'``, ``'yes'`` or ``'no'``
- 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.
-
-**EMAIL_SIGNATURE_POSITION** - ``'top'``, ``'bottom'`` or ``'none'``
- Where to place the email signature in messages that Roundup generates.
-
-**EMAIL_KEEP_QUOTED_TEXT** - ``'yes'`` or ``'no'``
- Keep email citations. Citations are the part of e-mail which the sender has
- quoted in their reply to previous e-mail with ``>`` or ``|`` characters at
- the start of the line.
-
-**EMAIL_LEAVE_BODY_UNCHANGED** - ``'no'``
- Preserve the email body as is. Enabiling this will cause the entire message
- body to be stored, including all citations, signatures and Outlook-quoted
- sections (ie. "Original Message" blocks). It should be either ``'yes'``
- or ``'no'``.
-
-**MAIL_DEFAULT_CLASS** - ``'issue'`` or ``''``
- Default class to use in the mailgw if one isn't supplied in email
- subjects. To disable, comment out the variable below or leave it blank.
-
-**HTML_VERSION** -  ``'html4'`` or ``'xhtml'``
- 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.
-
-**EMAIL_CHARSET** - ``utf-8`` (or ``iso-8859-1`` for Eudora users)
- 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'.
-
-**DEFAULT_TIMEZONE** - ``0``
- Numeric hour timezone offest to be used when displaying local times.
- The default timezone is used when users do not choose their own in
- their settings.
-
-The default config.py is given below - as you
-can see, the MAIL_DOMAIN must be edited before any interaction with the
-tracker is attempted.::
-
-    # roundup home is this package's directory
-    TRACKER_HOME=os.path.split(__file__)[0]
-
-    # The SMTP mail host that roundup will use to send mail
-    MAILHOST = 'localhost'
-
-    # The domain name used for email addresses.
-    MAIL_DOMAIN = 'your.tracker.email.domain.example'
-
-    # This is the directory that the database is going to be stored in
-    DATABASE = os.path.join(TRACKER_HOME, 'db')
-
-    # This is the directory that the HTML templates reside in
-    TEMPLATES = os.path.join(TRACKER_HOME, 'html')
-
-    # A descriptive name for your roundup tracker
-    TRACKER_NAME = 'Roundup issue tracker'
-
-    # The email address that mail to roundup should go to
-    TRACKER_EMAIL = 'issue_tracker@%s'%MAIL_DOMAIN
-
-    # 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.
-    TRACKER_WEB = 'http://tracker.example/cgi-bin/roundup.cgi/bugs/'
-
-    # The email address that roundup will complain to if it runs into
-    # trouble
-    ADMIN_EMAIL = 'roundup-admin@%s'%MAIL_DOMAIN
-
-    # 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@tracker.example>
-    # the EMAIL_FROM_TAG goes inside the "Foo Bar" quotes like so:
-    #    "Foo Bar EMAIL_FROM_TAG" <issue_tracker@tracker.example>
-    EMAIL_FROM_TAG = ""
-
-    # Send nosy messages to the author of the message
-    MESSAGES_TO_AUTHOR = 'no'           # either 'yes' or 'no'
-
-    # 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_AUTHOR_TO_NOSY = 'new'          # one of 'yes', 'no', '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.
-    ADD_RECIPIENTS_TO_NOSY = 'new'      # either 'yes', 'no', 'new'
-
-    # Where to place the email signature
-    EMAIL_SIGNATURE_POSITION = 'bottom' # one of 'top', 'bottom', 'none'
-
-    # Keep email citations
-    EMAIL_KEEP_QUOTED_TEXT = 'no'       # either 'yes' or 'no'
-
-    # Preserve the email body as is
-    EMAIL_LEAVE_BODY_UNCHANGED = 'no'   # either 'yes' or 'no'
-
-    # Default class to use in the mailgw if one isn't supplied in email
-    # subjects. To disable, comment out the variable below or leave it
-    # blank. Examples:
-    MAIL_DEFAULT_CLASS = 'issue'   # use "issue" class by default
-    #MAIL_DEFAULT_CLASS = ''        # disable (or just comment the var out)
-
-    # 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.
-    HTML_VERSION = 'html4'         # either 'html4' or 'xhtml'
-
-    # 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'.
-    EMAIL_CHARSET = 'utf-8'
-    #EMAIL_CHARSET = 'iso-8859-1'   # use this instead for Eudora users
-
-    # 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
-
-    # 
-    # SECURITY DEFINITIONS
-    #
-    # define the Roles that a user gets when they register with the
-    # tracker these are a comma-separated string of role names (e.g.
-    # 'Admin,User')
-    NEW_WEB_USER_ROLES = 'User'
-    NEW_EMAIL_USER_ROLES = 'User'
 
 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.
+.. 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 ``dbinit.py`` module of your
+Schemas are defined using Python code in the ``schema.py`` module of your
 tracker.
 
-The ``dbinit.py`` module
+The ``schema.py`` module
 ------------------------
 
-The ``dbinit.py`` module contains two functions:
+The ``schema.py`` module contains two functions:
 
 **open**
   This function defines what your tracker looks like on the inside, the
@@ -340,8 +498,10 @@ The ``dbinit.py`` module contains two functions:
 The "classic" schema
 --------------------
 
-The "classic" schema looks like this (see below for the meaning
-of ``'setkey'``)::
+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")
@@ -354,16 +514,17 @@ of ``'setkey'``)::
 
     user = Class(db, "user", username=String(), organisation=String(),
         password=String(), address=String(), realname=String(),
-        phone=String())
+        phone=String(), alternate_addresses=String(),
+        queries=Multilink('query'), roles=String(), timezone=String())
     user.setkey("username")
 
     msg = FileClass(db, "msg", author=Link("user"), summary=String(),
         date=Date(), recipients=Multilink("user"),
-        files=Multilink("file"))
+        files=Multilink("file"), messageid=String(), inreplyto=String())
 
-    file = FileClass(db, "file", name=String(), type=String())
+    file = FileClass(db, "file", name=String())
 
-    issue = IssueClass(db, "issue", topic=Multilink("keyword"),
+    issue = IssueClass(db, "issue", keyword=Multilink("keyword"),
         status=Link("status"), assignedto=Link("user"),
         priority=Link("priority"))
     issue.setkey('title')
@@ -375,8 +536,10 @@ What you can't do to the schema
 You must never:
 
 **Remove the users class**
-  This class is the only *required* class in Roundup. Similarly, its
-  username, password and address properties must never be removed.
+  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
@@ -470,6 +633,17 @@ A Class is comprised of one or more properties of the following types:
 * 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
 ~~~~~~~~~
@@ -479,7 +653,8 @@ 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.
+tracker. FileClasses also have a "type" attribute to store the MIME
+type of the file.
 
 
 IssueClass
@@ -523,12 +698,37 @@ or::
 
 Note, the same thing can be done in the web and e-mail interfaces. 
 
-If a class does not have an "order" property, the key is also used to
-sort instances of the class when it is rendered in the user interface.
-(If a class has no "order" property, sorting is by the labelproperty of
-the class. This is computed, in order of precedence, as the key, the
-"name", the "title", or the first property alphabetically.)
+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)
 ~~~~~~~~~~~~~~~~~~~
@@ -537,10 +737,30 @@ 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
+The Roundup wiki has examples of how schemas can be customised to add
+new functionality.
 
 
 Detectors - adding behaviour to your tracker
@@ -549,7 +769,7 @@ Detectors - adding behaviour to your tracker
 
 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.
+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
@@ -691,15 +911,16 @@ 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).
+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.
+.. 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
@@ -707,8 +928,8 @@ 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 dbinit module in your tracker to alter the items created in
-    using the ``create()`` methods.
+    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
@@ -728,62 +949,144 @@ Security / Access Controls
 
 A set of Permissions is built into the security module by default:
 
+- Create (everything)
 - Edit (everything)
 - View (everything)
 
-Every Class you define in your tracker's schema also gets an Edit and View
-Permission of its own.
-
-The default interfaces define:
-
-- Web Registration
-- Web Access
-- Web Roles
-- Email Registration
-- Email Access
+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 (Edit everything, View everything, Web Roles)
-- User (Web Access, Email Access)
-- Anonymous (Web Registration, Email Registration)
+- 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 database is initialised on
-installation. The two default schemas then define:
+user gets "Anonymous" assigned when the tracker is installed.
+
+For the "User" Role, the "classic" tracker defines:
 
-- Edit issue, View issue (both)
-- Edit file, View file (both)
-- Edit msg, View msg (both)
-- Edit support, View support (extended only)
+- Create, Edit and View issue, file, msg, query, keyword 
+- View priority, status
+- View user
+- Edit their own user record
 
-and assign those Permissions to the "User" Role. Put together, these
-settings appear in the ``open()`` function of the tracker ``dbinit.py``
-(the following is taken from the "minimal" template's ``dbinit.py``)::
+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::
 
     #
-    # SECURITY SETTINGS
+    # TRACKER SECURITY SETTINGS
     #
-    # 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)
+    # 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
-    p = db.security.getPermission('View', 'user')
+    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)
 
-    # 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)
+    #
+    # 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
@@ -794,6 +1097,9 @@ 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
 ------------------------
@@ -811,7 +1117,7 @@ Adding a new Permission
 
 When adding a new Permission, you will need to:
 
-1. add it to your tracker's dbinit so it is created, using
+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',
@@ -824,26 +1130,44 @@ When adding a new Permission, you will need to:
 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
 ~~~~~~~~~~~~~~~~~
 
-**automatic registration of users in the e-mail gateway**
- By giving the "anonymous" user the "Email Registration" Role, 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). Note: this is the default
- behaviour in the tracker templates that ship with Roundup.
+See the `examples`_ section for longer examples of customisation.
 
 **anonymous access through the e-mail gateway**
- Give the "anonymous" user the "Email Access" and ("Edit", "issue")
- Roles but do not not give them the "Email Registration" Role. 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
- "Email Registration" Role, 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*.
+ 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). By default new Roundup
+ trackers don't allow this as it opens them up to spam. It may be enabled
+ by uncommenting the appropriate addPermissionToRole in your tracker's
+ ``schema.py`` file. 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
@@ -892,10 +1216,11 @@ 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
-tracker ``interfaces.Client`` class - which uses the ``Client`` class
-from ``roundup.cgi.client`` - 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.
+``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
 ---------------------------------------------
@@ -908,6 +1233,7 @@ the web interface knows about it:
 2. The "page" template may require links to be changed, as might the
    "home" page's content arguments.
 
+
 How requests are processed
 --------------------------
 
@@ -934,6 +1260,7 @@ In some situations, exceptions occur:
     this exception percolates up to the CGI interface that called the
     client
 
+
 Determining web context
 -----------------------
 
@@ -943,20 +1270,23 @@ identifier is examined. Typical URL paths look like:
 
 1.  ``/tracker/issue``
 2.  ``/tracker/issue1``
-3.  ``/tracker/@file/style.css``
+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
+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.
-b. if the path starts with "@file" (as in example 3,
-   "/tracker/@file/style.css"), then the additional path entry,
+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 "html" directory. Raises a SendStaticFile exception.
+   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"
@@ -978,11 +1308,30 @@ defaults to:
 - 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.
+``@@file`` paths.
 
 
 Performing actions in web requests
@@ -1033,40 +1382,33 @@ of:
 
 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`` which also
-happens to be available in your tracker instance as ``interfaces.Client``. So
-if you need to define new actions, you may add them there (see `defining new
+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
-are:
+for each action are:
 
 **login**
- Determine whether the user has permission to log in. Base behaviour is
- to check the user has "Web Access".
+ Determine whether the user has the "Web Access" Permission.
 **logout**
  No permission checks are made.
 **register**
- Determine whether the user has permission to register. Base behaviour
- is to check the user has the "Web Registration" Permission.
+ Determine whether the user has the ("Create", "user") Permission.
 **edit**
- Determine whether the user has permission to edit this item. Base
- behaviour is to check whether the user can edit this class. If we're
+ 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 (or edit) this
- item. Base behaviour is to check the user can edit this class. No
+ 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 "Web Registration" Permission.
+ be created if the user has the ("Create", "user") Permission.
 **editCSV**
- Determine whether the user has permission to edit this class. Base
- behaviour is to check whether the user may edit this class.
+ Determine whether the user has permission to edit this class.
 **search**
- Determine whether the user has permission to search this class. Base
- behaviour is to check whether the user may view this class.
+ Determine whether the user has permission to view this class.
 
 
 Special form variables
@@ -1083,10 +1425,32 @@ variables and their values. You can:
 - 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>``
@@ -1145,11 +1509,11 @@ None of the above (ie. just a simple form value)
 
     For a Link('klass') property, the form value is a
     single key for 'klass', where the key field is
-    specified in dbinit.py.  
+    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 dbinit.py.  
+    key field is specified in schema.py.  
 
     Note that for simple-form-variables specifiying Link
     and Multilink properties, the linked-to class must
@@ -1201,13 +1565,12 @@ 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.py`` to ``'xhtml'`` instead of ``'html4'``.
+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
@@ -1246,7 +1609,7 @@ of files in there. The *minimal* template includes:
 
 The *classic* template has a number of additional templates.
 
-Note: Remember that you can create any template extension you want to,
+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::
@@ -1264,8 +1627,8 @@ 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:
+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
@@ -1306,7 +1669,10 @@ TAL commands are:
      </tr>
 
    The example would iterate over the sequence of users returned by
-   "user/list" and define the local variable "u" for each entry.
+   "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::
@@ -1358,12 +1724,17 @@ 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
 ~~~~~~~~~~~~~~~~~~~~~~
 
-The expressions you may use in the attribute values may be one of the
-following forms:
+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
@@ -1420,6 +1791,10 @@ Modifiers:
    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
 ~~~~~~~~~~~~~~~
@@ -1430,8 +1805,8 @@ 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:
+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
@@ -1477,16 +1852,18 @@ Language, or METAL. The macro commands are:
   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
+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
 ----------------------------------
 
-Note: this is implemented by
-``roundup.cgi.templating.RoundupPageTemplate``
+This is implemented by ``roundup.cgi.templating.RoundupPageTemplate``
 
 The following variables are available to templates.
 
@@ -1498,12 +1875,17 @@ The following variables are available to templates.
    - 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*
+   - "form"
      The current CGI form information as a mapping of form argument name
-     to value
+     to value (specifically a cgi.FieldStorage)
+   - "env" the CGI environment variables
+   - "base" the base URL for this instance
+   - "user" a HTMLItem instance for the current user
+   - "language" as determined by the browser or config
+   - "classname" the current classname (possibly None)
+   - "template" the current template (suffix, also possibly None)
 **config**
-  This variable holds all the values defined in the tracker config.py
+  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.
@@ -1536,6 +1918,23 @@ The following variables are available to templates.
 
     <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
 ~~~~~~~~~~~~~~~~~~~~
@@ -1563,7 +1962,7 @@ item. The only real difference between cases 2 and 3 above are:
 Hyperdb class wrapper
 :::::::::::::::::::::
 
-Note: this is implemented by the ``roundup.cgi.templating.HTMLClass``
+This is implemented by the ``roundup.cgi.templating.HTMLClass``
 class.
 
 This wrapper object provides access to a hyperb class. It is used
@@ -1586,10 +1985,64 @@ properties  return a `hyperdb property wrapper`_ for all of this class's
 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 by the
-            current *request* filterspec/filter/sort/group args
+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 an optionally empty list ``[(dir, prop)]``
+               where dir is '+', '-' or None
+               and prop is a prop name or None.
+
+               The propname in filterspec and prop in a sort/group spec
+               may be transitive, i.e., it may contain properties of
+               the form link.link.link.name.
+
+            eg. All issues with a priority of "1" with messages added in
+            the last week, sorted by activity date:
+            ``issue.filter(filterspec={"priority": "1",
+            'messages.creation' : '.-1w;'}, sort=[('activity', '+')])``
+
+filter_sql  **Only in SQL backends**
+
+            Lists the items that match the SQL provided. The SQL is a
+            complete "select" statement.
+
+            The SQL select must include the item id as the first column.
+
+            This function **does not** filter out retired items, add
+            on a where clause "__retired__ <> 1" if you don't want
+            retired nodes.
+
 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' :)
@@ -1609,7 +2062,7 @@ will access the "list" property, rather than the list method.
 Hyperdb item wrapper
 ::::::::::::::::::::
 
-Note: this is implemented by the ``roundup.cgi.templating.HTMLItem``
+This is implemented by the ``roundup.cgi.templating.HTMLItem``
 class.
 
 This wrapper object provides access to a hyperb item.
@@ -1630,12 +2083,30 @@ 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
+                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    generates a url-quoted link for download of FileClass
+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
@@ -1650,7 +2121,7 @@ will access the "journal" property, rather than the journal method.
 Hyperdb property wrapper
 ::::::::::::::::::::::::
 
-Note: this is implemented by subclasses of the
+This is implemented by subclasses of the
 ``roundup.cgi.templating.HTMLProperty`` class (``HTMLStringProperty``,
 ``HTMLNumberProperty``, and so on).
 
@@ -1704,15 +2175,30 @@ plain       render a "plain" representation of the property. This method
   
               "structure python:msg.content.plain(hyperlink=1)"
 
-             Note also that the text is automatically HTML-escaped before
-             the hyperlinking transformation.
+             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.
+            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.
+
+            popcal (Date properties only)
+              Include the Javascript-based popup calendar for date
+              selection. Defaults to on.
+
 stext       only on String properties - render the value of the property
             as StructuredText (requires the StructureText module to be
             installed separately)
@@ -1742,14 +2228,62 @@ pretty      Date properties - render the date as "dd Mon YYYY" (eg. "19
             Will format as "2004-03-19" instead.
 
             Interval properties - render the interval in a pretty
-            format (eg. "yesterday")
+            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" />
+
+            you still need to include the ``field`` for the property, so
+            typically you'd have::
+
+              <span tal:replace="structure context/due/field" />
+              <span tal:replace="structure context/due/popcal" />
+
 menu        only on Link and Multilink properties - render a form select
-            list for this property
+            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::
@@ -1766,7 +2300,7 @@ have permission to view the information.
 The request variable
 ~~~~~~~~~~~~~~~~~~~~
 
-Note: this is implemented by the ``roundup.cgi.templating.HTMLRequest``
+This is implemented by the ``roundup.cgi.templating.HTMLRequest``
 class.
 
 The request variable is packed with information about the current
@@ -1794,10 +2328,11 @@ 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)
+sort        index sort columns [(direction, column name)]
+group       index grouping properties [(direction, column name)]
 filter      properties to filter the index on
-filterspec  values to filter the index on
+filterspec  values to filter the index on (property=value, eg
+            ``priority=1`` or ``messages.author=42``
 search_text text to perform a full-text search on for an index
 =========== ============================================================
 
@@ -1842,7 +2377,7 @@ best to know beforehand what you're dealing with.
 The db variable
 ~~~~~~~~~~~~~~~
 
-Note: this is implemented by the ``roundup.cgi.templating.HTMLDatabase``
+This is implemented by the ``roundup.cgi.templating.HTMLDatabase``
 class.
 
 Allows access to all hyperdb classes as attributes of this variable. If
@@ -1861,7 +2396,7 @@ The access results in a `hyperdb class wrapper`_.
 The templates variable
 ~~~~~~~~~~~~~~~~~~~~~~
 
-Note: this is implemented by the ``roundup.cgi.templating.Templates``
+This is implemented by the ``roundup.cgi.templating.Templates``
 class.
 
 This variable doesn't have any useful methods defined. It supports being
@@ -1885,11 +2420,39 @@ 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
 ~~~~~~~~~~~~~~~~~~
 
-Note: this is implemented by the
+This is implemented by the
 ``roundup.cgi.templating.TemplatingUtils`` class, but it may be extended
 as described below.
 
@@ -1899,13 +2462,16 @@ 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
-``interfaces.py`` module's ``TemplatingUtils`` class. See `adding a time
-log to your issues`_ for an example. The TemplatingUtils class itself
-will have a single attribute, ``client``, which may be used to access
-the ``client.db`` when you need to perform arbitrary database queries.
+``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
 ::::::::
@@ -1940,7 +2506,7 @@ addition, it has several more attributes:
 =============== ========================================================
 Attribute       Description
 =============== ========================================================
-start           indicates the start index of the batch. *Note: unlike
+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*
@@ -1974,6 +2540,7 @@ An example of batching::
 ... which will produce a table with four columns containing the items of
 the "keyword" class (well, their "name" anyway).
 
+
 Displaying Properties
 ---------------------
 
@@ -1997,12 +2564,12 @@ 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
+    /issue?status=unread,in-progress,resolved&
+        keyword=security,ui&
+        @group=priority,-status&
+        @sort=-activity&
+        @filters=status,keyword&
+        @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
@@ -2020,19 +2587,49 @@ 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.
+displayed, and only items with "keyword" values including both "security"
+and "ui" are displayed. The items are grouped by priority arranged in
+ascending order and in descending order by status; and within
+groups, sorted by activity, arranged in descending order. The filter
+section shows filters for the "status" and "keyword" properties, and the
+table includes columns for the "title", "status", and "fixer"
+properties.
+
+============ =============================================================
+Argument     Description
+============ =============================================================
+@sort        sort by prop name, optionally preceeded with '-' to give
+             descending or nothing for ascending sorting. Several
+             properties can be specified delimited with comma.
+             Internally a search-page using several sort properties may
+             use @sort0, @sort1 etc. with option @sortdir0, @sortdir1
+             etc. for the direction of sorting (a non-empty value of
+             sortdir0 specifies reverse order).
+@group       group by prop name, optionally preceeded with '-' or to sort
+             in descending or nothing for ascending order. Several
+             properties can be specified delimited with comma.
+             Internally a search-page using several grouping properties may
+             use @group0, @group1 etc. with option @groupdir0, @groupdir1
+             etc. for the direction of grouping (a non-empty value of
+             groupdir0 specifies reverse order).
+@columns     selects the columns that should be displayed. Default is
+             all.                     
+@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).
+@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.
+.. 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
@@ -2040,8 +2637,8 @@ This is one of the class context views. The template used is typically
 
 - 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.
+- 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,
@@ -2051,18 +2648,18 @@ 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.
 
-The two special form values on search pages which are handled by the
-"search" action are:
+If the search view does specify the "search" ``@action``, then it may also
+provide an additional argument:
 
-:search_text
-  Text with which to perform a search of the text index. Results from
-  that search will be used to limit the results of other filters (using
-  an intersection operation)
-:query_name
-  If supplied, the search 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.
+============ =============================================================
+Argument     Description
+============ =============================================================
+@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
@@ -2238,12 +2835,15 @@ templating through the "journal" method of the item*::
 
 *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 ``interfaces.py`` as ``Action`` classes that get
-called by the the ``Client`` class.
+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
@@ -2257,16 +2857,18 @@ issues`_" for an example.
 Define the new action class
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-The action classes have the following interface::
+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 your tracker ``instance.Client``
-class - thus it's mostly implemented by ``roundup.cgi.client.Client``. See the
-docstring of that class for details of what it can do.
+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:
@@ -2281,15 +2883,15 @@ It may then:
 Register the action class
 ~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-The class is now written, but isn't available to the user until you add it to
-the ``instance.Client`` class ``actions`` variable, like so::
+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::
 
-    actions = client.Client.actions + (
-        ('myaction', myActionClass),
-    )
+    def init(instance):
+        instance.registerAction('myaction', myActionClass)
 
 This maps the action name "myaction" to the action class we defined.
 
+
 Use the new action
 ~~~~~~~~~~~~~~~~~~
 
@@ -2317,6 +2919,45 @@ 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
 ========
 
@@ -2335,6 +2976,70 @@ 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.py``::
+
+    issue = IssueClass(db, "issue", 
+                    assignedto=Link("user"), keyword=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>
+    
+   If you want to show only the date part of due_date then do this instead::
+   
+    <tr> 
+     <th>Due Date</th> 
+     <td tal:content="structure python:context.due_date.field(format='%Y-%m-%d')" /> 
+    </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" />
+          
+   If you want format control of the display of the due date you can
+   enter the following in the data row to show only the actual due date::
+    
+      <td tal:condition="request/show/due_date" 
+          tal:content="python:i.due_date.pretty('%Y-%m-%d')">&nbsp;</td>
+
+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>
+
+5. If you wish for the due date to appear in the standard views listed
+   in the sidebar of the web interface then you'll need to add "due_date"
+   to the columns and columns_showall lists in your ``page.html``::
+    
+    columns string:id,activity,due_date,title,creator,status;
+    columns_showall string:id,activity,due_date,title,creator,assignedto,status;
+
+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.
 
@@ -2342,8 +3047,8 @@ 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 needed an extra data field per
+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
@@ -2357,8 +3062,8 @@ 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 ``open()`` function in ``dbinit.py``. Under the
-comment::
+to add some lines to the ``schema.py`` file of your tracker instance.
+Under the comment::
 
     # add any additional database schema configuration here
 
@@ -2379,8 +3084,8 @@ 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 the ``open()`` function
-in ``dbinit.py`` which set up the "issue" class, and then add a link to
+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", ... ,
@@ -2397,19 +3102,19 @@ 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
+If you haven't initialised the database with the ``roundup-admin``
 "initialise" command, then you can add the following to the tracker
-``dbinit.py`` in the ``init()`` function under the comment::
+``initial_data.py`` under the comment::
 
-    # add any additional database create steps here - but only if you
+    # 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", order="1")
-     category.create(name="chaco", order="2")
-     category.create(name="weave", order="3")
+     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::
@@ -2417,17 +3122,15 @@ If the database has already been initalised, then you need to use the
      % roundup-admin -i <tracker home>
      Roundup <version> ready for input.
      Type "help" for help.
-     roundup> create category name=scipy order=1
+     roundup> create category name=scipy
      1
-     roundup> create category name=chaco order=1
+     roundup> create category name=chaco
      2
-     roundup> create category name=weave order=1
+     roundup> create category name=weave
      3
      roundup> exit...
      There are unsaved changes. Commit them (y/N)? y
 
-TODO: explain why order=1 in each case.
-
 
 Setting up security on the new objects
 ::::::::::::::::::::::::::::::::::::::
@@ -2438,44 +3141,21 @@ 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 the ``open()`` function of ``dbinit.py``.
+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::
 
-    # new permissions for this schema
-    for cl in 'issue', 'file', 'msg', 'user', 'category':
-        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', 'category':
         p = db.security.getPermission('View', cl)
-        db.security.addPermissionToRole('User', p)
-        p = db.security.getPermission('Edit', cl)
-        db.security.addPermissionToRole('User', p)
-
-So you are in effect doing the following (with 'cl' substituted by its
-value)::
+        db.security.addPermissionToRole('User', 'View', cl)
+        db.security.addPermissionToRole('User', 'Edit', cl)
+        db.security.addPermissionToRole('User', 'Create', cl)
 
-    db.security.addPermission(name="Edit", klass='category',
-        description="User is allowed to edit "+'category')
-    db.security.addPermission(name="View", klass='category',
-        description="User is allowed to access "+'category')
-
-which is creating two permission types; that of editing and viewing
-"category" objects respectively. Then the following lines assign those
-new permissions to the "User" role, so that normal users can view and
-edit "category" objects::
-
-    p = db.security.getPermission('View', 'category')
-    db.security.addPermissionToRole('User', p)
-
-    p = db.security.getPermission('Edit', 'category')
-    db.security.addPermissionToRole('User', p)
+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
@@ -2488,13 +3168,13 @@ 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``, which is what we are going to be editing next.
+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. Under the end of the classblock for issue is where we
-are going to add the category code::
+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')">
@@ -2519,7 +3199,7 @@ 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
+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.
 
@@ -2536,7 +3216,7 @@ 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
+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::
@@ -2576,10 +3256,10 @@ happening::
      <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
+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 (which is
-"category"). The upshot of this is that when the user types something in
+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>
@@ -2653,13 +3333,15 @@ 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
+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 db/category/classhelp" />
+   <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
@@ -2675,19 +3357,19 @@ which contains the list of currently known categories.
 Searching on categories
 :::::::::::::::::::::::
 
-We can add categories, and create issues with categories. The next
+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
+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 be starting to seem familiar, although it
+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::
 
@@ -2701,16 +3383,16 @@ like within that form::
     <td metal:use-macro="group_input"></td>
   </tr>
 
-The definitions in the <tr> opening tag are used by the macros:
+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
-  group on.
+- ``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::
 
@@ -2726,8 +3408,8 @@ The category search code above would expand to the following::
       </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>
+    <td><input type="radio" name=":sort0" value="category"></td>
+    <td><input type="radio" name=":group0" value="category"></td>
   </tr>
 
 Adding category to the default view
@@ -2765,7 +3447,7 @@ 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 "Unasigned Issues" or "All Issues",
+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
@@ -2778,7 +3460,7 @@ 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 ``dbinit.py``::
+1. Add a new class to your tracker ``schema.py``::
 
     # storage for time logging
     timelog = Class(db, "timelog", period=Interval())
@@ -2786,11 +3468,21 @@ be able to give a summary of the total time spent on a particular issue.
    Note that we automatically get the date of the time log entry
    creation through the standard property "creation".
 
+   You will need to grant "Creation" permission to the users who are
+   allowed to add timelog entries. You may do this with::
+
+    db.security.addPermissionToRole('User', 'Create', 'timelog')
+    db.security.addPermissionToRole('User', 'View', 'timelog')
+
+   If users are also able to *edit* timelog entries, then also include::
+
+    db.security.addPermissionToRole('User', 'Edit', 'timelog')
+
 2. Link to the new class from your issue class (again, in
-   ``dbinit.py``)::
+   ``schema.py``)::
 
     issue = IssueClass(db, "issue", 
-                    assignedto=Link("user"), topic=Multilink("keyword"),
+                    assignedto=Link("user"), keyword=Multilink("keyword"),
                     priority=Link("priority"), status=Link("status"),
                     times=Multilink("timelog"))
 
@@ -2798,17 +3490,17 @@ be able to give a summary of the total time spent on a particular issue.
 
 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
+   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
+   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 perdiod::
+   field to capture a new timelog item's period::
 
     <tr> 
      <th>Time Log</th> 
      <td colspan=3><input type="text" name="timelog-1@period" /> 
-      <br />(enter as '3y 1m 4d 2:40:02' or parts thereof) 
+      (enter as '3y 1m 4d 2:40:02' or parts thereof) 
      </td> 
     </tr> 
          
@@ -2821,31 +3513,43 @@ be able to give a summary of the total time spent on a particular issue.
    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.
+   
+   The full entry will now look like this::
+   
+    <tr> 
+     <th>Time Log</th> 
+     <td colspan=3><input type="text" name="timelog-1@period" /> 
+      (enter as '3y 1m 4d 2:40:02' or parts thereof)
+      <input type="hidden" name="@link@times" value="timelog-1" /> 
+     </td> 
+    </tr> 
+   
 
-4. We want to display a total of the time log times that have been
+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 method to the
-   TemplatingUtils class in our tracker ``interfaces.py`` module::
+   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::
+
+    from roundup import date
 
-    class TemplatingUtils:
-        ''' Methods implemented on this class will be available to HTML
-            templates through the 'utils' variable.
+    def totalTimeSpent(times):
+        ''' Call me with a list of timelog items (which have an
+            Interval "period" property)
         '''
-        def totalTimeSpent(self, 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
+        total = date.Interval('0d')
+        for time in times:
+            total += time.period._value
+        return total
+
+    def init(instance):
+        instance.registerUtil('totalTimeSpent', totalTimeSpent)
 
-   Replace the ``pass`` line if one appears in your TemplatingUtils
-   class. As indicated in the docstrings, we will be able to access the
-   ``totalTimeSpent`` method via the ``utils`` variable in our templates.
+   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 time log for an issue::
+5. Display the timelog for an issue::
 
      <table class="otherinfo" tal:condition="context/times">
       <tr><th colspan="3" class="header">Time Log
@@ -2866,11 +3570,69 @@ be able to give a summary of the total time spent on a particular issue.
    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
+6. 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.
 
+An extension of this modification attaches the timelog entries to any
+change message entered at the time of the timelog entry:
+
+A. Add a link to the timelog to the msg class in ``schema.py``:
+
+    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(),
+                    times=Multilink("timelog"))
+
+B. Add a new hidden field that links that new timelog item (new
+   because it's marked as having id "-1") to the new message.
+   The link is placed in ``issue.item.html`` in the same section that
+   handles the timelog entry.
+   
+   It looks like this after this addition::
+
+    <tr> 
+     <th>Time Log</th> 
+     <td colspan=3><input type="text" name="timelog-1@period" /> 
+      (enter as '3y 1m 4d 2:40:02' or parts thereof)
+      <input type="hidden" name="@link@times" value="timelog-1" />
+      <input type="hidden" name="msg-1@link@times" value="timelog-1" /> 
+     </td> 
+    </tr> 
+   The "times" property of the message will have the new id added to it.
+
+C. Add the timelog listing from step 5. to the ``msg.item.html`` template
+   so that the timelog entry appears on the message view page. Note that
+   the call to totalTimeSpent is not used here since there will only be one
+   single timelog entry for each message.
+   
+   I placed it after the Date entry like this::
+   
+    <tr>
+     <th i18n:translate="">Date:</th>
+     <td tal:content="context/date"></td>
+    </tr>
+    </table>
+    
+    <table class="otherinfo" tal:condition="context/times">
+     <tr><th colspan="3" class="header">Time Log</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>
+    
+    <table class="messages">
+
 
 Tracking different types of issues
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -2878,51 +3640,54 @@ 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 customer support issue class to a tracker.
+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 ``dbinit.py`` - in this
-   example, we're adding a "system support" class. Just after the "issue"
-   class definition in the "open" function, add::
+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"),
+                    assignedto=Link("user"), keyword=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
+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".
+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".
+   existing issues one, changing the ``issue`` class name to ``support``.
 
-6. Re-start your tracker and start using the new "support" class.
+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::
 
-    p = db.security.getPermission('View', 'support')
-    db.security.addPermissionToRole('SysAdmin', p)
-    p = db.security.getPermission('Edit', 'support')
-    db.security.addPermissionToRole('SysAdmin', p)
+    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"
+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.
 
@@ -2933,6 +3698,12 @@ 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
@@ -2945,13 +3716,18 @@ would be::
 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 with our
-``Client`` class in the tracker home ``interfaces.py`` module::
+``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')
 
@@ -2968,15 +3744,10 @@ need to override the standard ``verifyPassword`` method defined in
             # user doesn't exist in the file
             return 0
 
-    class Client(client.Client):
-        actions = client.Client.actions + (
-            ('login', ExternalPasswordLoginAction)
-        )
-
-What this does is look through the file, line by line, looking for a
-name that matches.
+    def init(instance):
+        instance.registerAction('login', ExternalPasswordLoginAction)
 
-We also remove the redundant password fields from the ``user.item``
+You should also remove the redundant password fields from the ``user.item``
 template.
 
 
@@ -2996,7 +3767,7 @@ 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 users lists in sync using a fairly
+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:
 
@@ -3011,7 +3782,7 @@ immediate access is needed. In short, it:
 
 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.
+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
@@ -3030,8 +3801,8 @@ tracker we're to work on::
 
 Next we read in the *passwd* file from the tracker home::
 
-    # read in the users
-    file = os.path.join(tracker_home, 'users.passwd')
+    # 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
@@ -3127,11 +3898,11 @@ 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
+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 interfaces.py
-module (see `using an external password validation source`_). The method is
-implemented by default as::
+``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
@@ -3156,6 +3927,37 @@ So you could reimplement this as something like::
 Changes to Tracker Behaviour
 ----------------------------
 
+Preventing SPAM
+~~~~~~~~~~~~~~~
+
+The following detector code may be installed in your tracker's
+``detectors`` directory. It will block any messages being created that
+have HTML attachments (a very common vector for spam and phishing)
+and any messages that have more than 2 HTTP URLs in them. Just copy
+the following into ``detectors/anti_spam.py`` in your tracker::
+
+    from roundup.exceptions import Reject
+
+    def reject_html(db, cl, nodeid, newvalues):
+        if newvalues['type'] == 'text/html':
+        raise Reject, 'not allowed'
+
+    def reject_manylinks(db, cl, nodeid, newvalues):
+        content = newvalues['content']
+        if content.count('http://') > 2:
+        raise Reject, 'not allowed'
+
+    def init(db):
+        db.file.audit('create', reject_html)
+        db.msg.audit('create', reject_manylinks)
+
+You may also wish to block image attachments if your tracker does not
+need that ability::
+
+    if newvalues['type'].startswith('image/'):
+        raise Reject, 'not allowed'
+
+
 Stop "nosy" messages going to people on vacation
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
@@ -3256,8 +4058,8 @@ vacation". Not very useful, and relatively easy to stop.
 Adding in state transition control
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-Sometimes tracker admins want to control the states that users may move
-issues to. You can do this by following these steps:
+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``
@@ -3265,7 +4067,7 @@ issues to. You can do this by following these steps:
 
      <input type="hidden" name="@required" value="status">
 
-   this will force users to select a status.
+   This will force users to select a status.
 
 2. add a Multilink property to the status class::
 
@@ -3275,7 +4077,7 @@ issues to. You can do this by following these steps:
    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.
+   b. using the ``roundup-admin`` "set" command.
 
 3. add an auditor module ``checktransition.py`` in your tracker's
    ``detectors`` directory, for example::
@@ -3332,47 +4134,50 @@ 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")``. Edit your tracker's dbinit.py file.
-   Where the "issue" class is defined, something like::
+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"),
+                    assignedto=Link("user"), keyword=Multilink("keyword"),
                     priority=Link("priority"), status=Link("status"))
 
-   add the blockers entry like so::
+   to this, adding the blockers entry::
 
     issue = IssueClass(db, "issue", 
                     blockers=Multilink("issue"),
-                    assignedto=Link("user"), topic=Multilink("keyword"),
+                    assignedto=Link("user"), keyword=Multilink("keyword"),
                     priority=Link("priority"), status=Link("status"))
 
-2. Add the new "blockers" property to the issue.item edit page, using
-   something like::
+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:replace="structure python:db.issue.classhelp('id,title',
+                                  property='blockers')" />
      <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>
+    </td>
 
    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 (attached) which enforces the rules:
+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.
         '''
@@ -3403,18 +4208,21 @@ resolved. To achieve this:
         if newvalues['status'] == resolved_id:
             raise ValueError, "This issue can't be resolved until %s resolved."%s
 
-    def resolveblockers(db, cl, nodeid, newvalues):
+
+    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.
         '''
-        if not newvalues.has_key('status'):
+        newstatus = cl.get(nodeid,'status')
+
+        # no change?
+        if oldvalues.get('status', None) == newstatus:
             return
 
-        # get the resolved state ID
         resolved_id = db.status.lookup('resolved')
 
         # interesting?
-        if newvalues['status'] != resolved_id:
+        if newstatus != resolved_id:
             return
 
         # yes - find all the blocked issues, if any, and remove me from
@@ -3426,7 +4234,6 @@ resolved. To achieve this:
                 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)
@@ -3444,12 +4251,36 @@ resolved. To achieve this:
    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>
+    <a href="#"
+       tal:attributes="href python:request.indexargs_url('issue', {
+      '@sort': '-activity',
+      '@group': 'priority',
+      '@filter': 'status',
+      '@columns': columns_showall,
+      '@search_text': '',
+      'status': status_notresolved,
+      '@dispname': i18n.gettext('Show All'),
+     })"
+       i18n:translate="">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>
+   "@filter" *and* "blockers" values must be specified)::
+
+    <a href="#"
+       tal:attributes="href python:request.indexargs_url('issue', {
+      '@sort': '-activity',
+      '@group': 'priority',
+      '@filter': 'status,blockers',
+      '@columns': columns_showall,
+      '@search_text': '',
+      'status': status_notresolved,
+      'blockers': '-1',
+      '@dispname': i18n.gettext('Show All'),
+     })"
+       i18n:translate="">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
@@ -3457,33 +4288,32 @@ 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
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+Add users to the nosy list based on the keyword
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-We need the ability to automatically add users to the nosy list based
-on the occurence of a topic. Every user should be allowed to edit his
-own list of topics for which he wants to be added to the nosy list.
+Let's say we need the ability to automatically add users to the nosy
+list based
+on the occurance of a keyword. Every user should be allowed to edit their
+own list of keywords for which they want to be added to the nosy list.
 
-Below will be showed that such a change can be performed with only
-minimal understanding of the roundup system, but with clever use
-of Copy and Paste.
+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 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 in the database to make 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`` seem to fullfill this (note that within
-the code topics are called ``keywords``.) As such, all what has to be
-done is to add a new field to the definition of ``user`` within the
-file ``dbinit.py``.  We will call this new field ``nosy_keywords``, and
-the updated definition of user will be::
+allow per-user recording of the lists of keywords for which he wants to
+be put on the nosy list, a change in the user view allowing them to edit
+this list of keywords, and addition of an auditor which updates the nosy
+list when a keyword is set.
+
+Adding the nosy keyword list
+::::::::::::::::::::::::::::
+
+The change to make in the database, is that for any user there should be a list
+of keywords for which he wants to be put on the nosy list. Adding a
+``Multilink`` of ``keyword`` seems to fullfill this. 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(),
@@ -3494,22 +4324,22 @@ the updated definition of user will be::
                     timezone=String(),
                     nosy_keywords=Multilink('keyword'))
  
-Changing the user view to allow changing the nosy topic list
-::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+Changing the user view to allow changing the nosy keyword list
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
 
-We want any user to be able to change the list of topics for which
+We want any user to be able to change the list of keywords 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 easily can
-see that the topic field in the issue view has very similar editting
-requirements as our nosy topics, both being a list of topics. As
-such, we search for Topics in ``issue.item.html``, and extract the
+We can easily 
+see that the keyword field in the issue view has very similar editing
+requirements as our nosy keywords, both being lists of keywords. As
+such, we look for Keywords 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>
+  <th>Nosy Keywords</th>
   <td>
   <span tal:replace="structure context/nosy_keywords/field" />
   <span tal:replace="structure python:db.keyword.classhelp(property='nosy_keywords')" />
@@ -3520,39 +4350,39 @@ E-mail addresses' in the classic template)::
 Addition of an auditor to update the nosy list
 ::::::::::::::::::::::::::::::::::::::::::::::
 
-The more difficult part is the addition of the logic to actually
-at the users to the nosy list when it is required. 
-The choice is made to perform this action when the topics on an
-item are set, including when an item is created.
+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 keywords 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 is sending the e-mail, which
-we do not need. As such, we can change the init function to::
+``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 settings
+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 in ``updatenosy`` handled adding the assignedto user(s)
-to the nosy list, should be replaced by a block of code to add the
+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 loop over all users,
-and assign the user to the nosy list when the topic in the user's
-nosy_keywords. The next part in ``updatenosy``, adding the author
-and/or recipients of a message to the nosy list, obviously is not
-relevant here and thus is deleted from the new auditor. The last
-part, copying the new nosy list to newvalues, does not have to be changed.
-This brings the following function::
+new keywords, than looping over all users,
+and assign the user to the nosy list when the keyword 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
+        '''Update the nosy list for changes to the keywords
         '''
         # nodeid will be None if this is a new node
         current = {}
@@ -3577,17 +4407,17 @@ This brings the following function::
                 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:
+        # add users with keyword in nosy_keywords to the nosy list
+        if newvalues.has_key('keyword') and newvalues['keyword'] is not None:
+            keyword_ids = newvalues['keyword']
+            for keyword in keyword_ids:
                 # loop over all users,
-                # and assign user to nosy when topic in nosy_keywords
+                # and assign user to nosy when keyword 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:
+                        if kw == keyword:
                             found = 1
                     if found:
                         current[user_id] = 1
@@ -3595,9 +4425,9 @@ This brings the following function::
         # that's it, save off the new nosy list
         newvalues['nosy'] = current.keys()
 
-and these two function are the only ones needed in the file.
+These two function are the only ones needed in the file.
 
-TODO: update this example to use the find() Class method.
+TODO: update this example to use the ``find()`` Class method.
 
 Caveats
 :::::::
@@ -3606,22 +4436,22 @@ 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 again is added to the nosy list 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.
+    from the nosy list, he is added to the nosy list again when the
+    keyword list of the issue is updated. A better design might be
+    to only check which keywords are new compared to the old list
+    of keywords, and only add users when they have indicated
+    interest on a new keyword.
 
-    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.
+    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
+    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 will be to link from the topics to the users which
-    selected these topics a nosy topics. This will eliminate the
+    A way out would be to link from the keywords to the users who
+    selected these keywords as nosy keywords. This will eliminate the
     loop over all users.
 
 Changes to Security and Permissions
@@ -3630,7 +4460,7 @@ Changes to Security and Permissions
 Restricting the list of users that are assignable to a task
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-1. In your tracker's "dbinit.py", create a new Role, say "Developer"::
+1. In your tracker's ``schema.py``, create a new Role, say "Developer"::
 
      db.security.addRole(name='Developer', description='A developer')
 
@@ -3644,7 +4474,7 @@ Restricting the list of users that are assignable to a task
 
      db.security.addPermissionToRole('Developer', p)
 
-4. In the issue item edit page ("html/issue.item.html" in your tracker
+4. In the issue item edit page (``html/issue.item.html`` in your tracker
    directory), use the new Permission in restricting the "assignedto"
    list::
 
@@ -3661,11 +4491,11 @@ Restricting the list of users that are assignable to a task
     </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)::
+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
+      ''' Ensure the assignedto value in newvalues is used with the
           Fixer Permission
       '''
       if not newvalues.has_key('assignedto'):
@@ -3684,74 +4514,90 @@ tracker "detectors" directory)::
 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
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-Users registering themselves are granted Provisional access - meaning they
+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".) We back up the permissions with
-an auditor.
+Own" on issues (regular users have "Edit".)
 
 First up, we create the new Role and Permission structure in
-``dbinit.py``::
+``schema.py``::
 
+    #
     # New users not approved by the admin
+    #
     db.security.addRole(name='Provisional User',
         description='New user registered via web or email')
-    p = db.security.addPermission(name='Edit Own', klass='issue',
-        description='Can only edit own issues')
-    db.security.addPermissionToRole('Provisional User', p)
 
-    # Assign the access and edit Permissions for issue to new users now
-    p = db.security.getPermission('View', 'issue')
+    # 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.getPermission('Edit', 'issue')
+    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
-    p = db.security.getPermission('Web Access')
+    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.getPermission('Email Access')
+    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 the ``config.py`` we change the Role assigned to newly-registered
+Then, in ``config.ini``, we change the Role assigned to newly-registered
 users, replacing the existing ``'User'`` values::
 
-    NEW_WEB_USER_ROLES = 'Provisional User'
-    NEW_EMAIL_USER_ROLES = 'Provisional User'
-
-Finally we add a new *auditor* to the ``detectors`` directory called
-``provisional_user_auditor.py``::
+    [main]
+    ...
+    new_web_user_roles = 'Provisional User'
+    new_email_user_roles = 'Provisional User'
 
- def audit_provisionaluser(db, cl, nodeid, newvalues):
-     ''' New users are only allowed to modify their own issues.
-     '''
-     if (db.getuid() != cl.get(nodeid, 'creator')
-         and db.security.hasPermission('Edit Own', db.getuid(), cl.classname)):
-         raise ValueError, ('You are only allowed to edit your own %s'
-                            % cl.classname)
 
- def init(db):
-     # fire before changes are made
-     db.issue.audit('set', audit_provisionaluser)
-     db.issue.audit('retire', audit_provisionaluser)
-     db.issue.audit('restore', audit_provisionaluser)
+All users may only view and edit issues, files and messages they create
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-Note that some older trackers might also want to change the ``page.html``
-template as follows::
+Replace the standard "classic" tracker View and Edit Permission assignments
+for the "issue", "file" and "msg" classes with the following::
 
- <p class="classblock"
- -       tal:condition="python:request.user.username != 'anonymous'">
- +       tal:condition="python:request.user.hasPermission('View', 'user')">
-     <b>Administration</b><br>
-     <tal:block tal:condition="python:request.user.hasPermission('Edit', None)">
-      <a href="home?:template=classlist">Class List</a><br>
+    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)
 
-(note that the "-" indicates a removed line, and the "+" indicates an added
-line).
 
 
 Changes to the Web User Interface
@@ -3760,7 +4606,7 @@ Changes to the Web User Interface
 Adding action links to the index page
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-Add a column to the item.index.html template.
+Add a column to the ``item.index.html`` template.
 
 Resolving the issue::
 
@@ -3772,19 +4618,19 @@ Resolving the issue::
   <a tal:attributes="href
      string:issue${i/id}?:assignedto=${request/user/id}&:action=edit">take</a>
 
-... and so on
+... 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 to the ``<tr>`` that displays the
-actual rows of data::
+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, like::
+different priorities, as follows::
 
    tr.priority-critical td {
        background-color: red;
@@ -3802,7 +4648,8 @@ 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, so at the top it reads::
+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">
@@ -3827,7 +4674,7 @@ To edit the status of all items in the item index view, edit the
 
    this will result in an edit field for the status property.
 
-3. after the ``tal:block`` which lists the actual index items (marked by
+3. after the ``tal:block`` which lists the index items (marked by
    ``tal:repeat="i batch"``) add a new table row::
 
     <tr>
@@ -3839,7 +4686,7 @@ To edit the status of all items in the item index view, edit the
     </tr>
 
    which gives us a submit button, indicates that we are performing an edit
-   on any changed statuses and the final block will make sure that the
+   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).
 
@@ -3847,7 +4694,7 @@ To edit the status of all items in the item index view, edit the
 Displaying only message summaries in the issue display
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-Alter the issue.item template section for messages to::
+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>
@@ -3871,7 +4718,7 @@ 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 one or the other. We'll use a new form variable,
+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">
@@ -3912,6 +4759,7 @@ that shows either one or the other. We'll use a new form variable,
   </tal:block>
  </table>
 
+
 Setting up a "wizard" (or "druid") for controlled adding of issues
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
@@ -3947,8 +4795,8 @@ Setting up a "wizard" (or "druid") for controlled adding of issues
        .
     </form>
 
-   Note that later in the form, I test the value of "cat" include form
-   elements that are appropriate. For example::
+   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>
@@ -3966,9 +4814,9 @@ Setting up a "wizard" (or "druid") for controlled adding of issues
 
 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 and insert hooks to those actions in
-   the "actions" attribute on on the ``interfaces.Client`` class, like so (see 
-   `defining new web actions`_)::
+   those actions in a new ``Action`` class (see `defining new web actions`_)::
+
+    from roundup.cgi.actions import Action
 
     class Page1SubmitAction(Action):
         def handle(self):
@@ -3982,14 +4830,30 @@ Setting up a "wizard" (or "druid") for controlled adding of issues
             # everything's ok, move on to the next page
             self.template = 'add_page2'
 
-    actions = client.Client.actions + (
-        ('page1_submit', Page1SubmitAction),
-    )
+    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.
+
 
 -------------------
 
@@ -3997,4 +4861,5 @@ Back to `Table of Contents`_
 
 .. _`Table of Contents`: index.html
 .. _`design documentation`: design.html
+.. _`admin guide`: admin_guide.html
 
index bc29452ba0112f1e51cc492ebdb55be8e3a0cc51..bd5b5ac1620e45c5e4565f8d82c64eca5bc31cf7 100644 (file)
@@ -1,15 +1,15 @@
 /*
 :Author: David Goodger
 :Contact: goodger@users.sourceforge.net
-:date: $Date: 2004-03-26 06:11:32 $
-:version: $Revision: 1.11 $
+: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: black }
+  color: blue }
 
 a.toc-backref {
   text-decoration: none ;
@@ -25,12 +25,23 @@ div.abstract p.topic-title {
   font-weight: bold ;
   text-align: center }
 
-div.attention, div.caution, div.danger, div.error, div.hint,
-div.important, div.note, div.tip, div.warning {
+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 {
@@ -75,8 +86,15 @@ div.system-message p.system-message-title {
 div.topic {
   margin: 2em }
 
+h1 {
+  margin-top: 2em;
+  text-decoration: underline;
+}
+
 h1.title {
-  text-align: center }
+  text-align: center;
+  margin-top: .5em;
+}
 
 h2.subtitle {
   text-align: center }
index 8856d37f532804838e10e7f33b0d95c414224244..4cee32eb5c1d52a67a2c84dcf3955552caf54ab3 100644 (file)
@@ -2,9 +2,7 @@
 Roundup - An Issue-Tracking System for Knowledge Workers
 ========================================================
 
-:Authors: Ka-Ping Yee (original__), Richard Jones (implementation)
-
-__ spec.html
+:Authors: Ka-Ping Yee (original), Richard Jones (implementation)
 
 .. contents::
 
@@ -14,10 +12,12 @@ 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
+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
 -----------------
@@ -50,13 +50,15 @@ Hyperdatabase
 -------------
 
 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 items.
+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 storage layer could be a
-third-party RDBMS; for a "batteries-included" distribution, implementing
-the hyperdatabase on the standard bsddb module is suggested.
+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
 ~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -450,27 +452,50 @@ Here is the interface provided by the hyperdatabase::
             id is returned; otherwise a KeyError is raised.
             """
 
-        def find(self, propname, itemid):
+        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: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.
+            '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. Used by the full text
-            indexing, which knows that "foo" occurs in msg1, msg3 and
-            file7, so we have hits on these issues:
+            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.
+            """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. Note that for
+            backward-compatibility reasons a single (dir, prop) tuple is
+            also allowed.
+
+            "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.
+
+            The propname in filterspec and prop in a sort/group spec may be
+            transitive, i.e., it may contain properties of the form
+            link.link.link.name, e.g. you can search for all issues where
+            a message was added by a certain user in the last week with a
+            filterspec of
+            {'messages.author' : '42', 'messages.creation' : '.-1w;'}
             """
 
         def list(self):
@@ -574,7 +599,7 @@ practice::
     4
     >>> db.issue.create(title="abuse", status=1)
     5
-    >>> hyperdb.Class(db, "user", username=hyperdb.Key(),
+    >>> hyperdb.Class(db, "user", username=hyperdb.String(),
     ... password=hyperdb.String())
     <hyperdb.Class "user">
     >>> db.issue.addprop(fixer=hyperdb.Link("user"))
@@ -726,12 +751,6 @@ hyperdatabase, except for the following changes and additional methods::
             properties or "actor" cause a KeyError.
             """
 
-        # New methods:
-
-        def audit(self, event, detector):
-        def react(self, event, detector):
-            """Register a detector (see below for more details)."""
-
     class IssueClass(Class):
         # Overridden methods:
 
@@ -800,7 +819,7 @@ software bug tracker.  The database is set up like this::
     Class(db, "keyword", name=hyperdb.String())
 
     Class(db, "issue", fixer=hyperdb.Multilink("user"),
-                       topic=hyperdb.Multilink("keyword"),
+                       keyword=hyperdb.Multilink("keyword"),
                        priority=hyperdb.Link("priority"),
                        status=hyperdb.Link("status"))
 
@@ -852,20 +871,22 @@ The ``audit()`` and ``react()`` methods register detectors on a given
 class of items::
 
     class Class:
-        def audit(self, event, detector):
+        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.
+            arguments. Detectors are called in priority order, execution
+            order is undefined for detectors with the same priority.
             """
 
-        def react(self, event, detector):
+        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.
+            arguments. Detectors are called in priority order, execution
+            order is undefined for detectors with the same priority.
             """
 
 Auditors are called with the arguments::
@@ -1229,10 +1250,10 @@ An index view specifier looks like this (whitespace has been added for
 clarity)::
 
     /issue?status=unread,in-progress,resolved&
-        topic=security,ui&
-        :group=priority&
+        keyword=security,ui&
+        :group=priority,-status&
         :sort=-activity&
-        :filters=status,topic&
+        :filters=status,keyword&
         :columns=title,status,fixer
 
 
@@ -1253,12 +1274,12 @@ 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.
+and only issues with "keyword" values including both "security" and "ui"
+are displayed.  The items are grouped by priority arranged in ascending
+order and in descending order by status; and within groups, sorted by
+activity, arranged in descending order. The filter section shows
+filters for the "status" and "keyword" 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
@@ -1382,11 +1403,12 @@ 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 two levels of Permission. The Class level permissions
+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.
+properties. The Property level permissions define logical permissions
+associated with a specific property of an item.
 
 
 Access Control Interface Specification
@@ -1399,11 +1421,20 @@ The security module defines::
             - 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:
@@ -1419,36 +1450,41 @@ The security module defines::
                 the base roles (for admin user).
             '''
 
-        def getPermission(self, permission, classname=None):
-            ''' Find the Permission matching the name and for the class,
-                if the classname is specified.
+        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):
+        def hasPermission(self, permission, userid, classname=None,
+                property=None, itemid=None):
             ''' Look through all the Roles, and hence Permissions, and
-                see if "permission" is there for the specified
-                classname.
-            '''
+                see if "permission" exists given the constraints of
+                classname, property and itemid.
 
-        def hasItemPermission(self, classname, itemid, **propspec):
-            ''' Check the named properties of the given item to see if
-                the userid appears in them. If it does, then the user is
-                granted this permission check.
+                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.
 
-                'propspec' consists of a set of properties and values
-                that must be present on the given item for access to be
-                granted.
+                If property is specified, the Permission matched must have
+                either no properties listed or the property must appear in
+                the list.
 
-                If a property is a Link, the value must match the
-                property value. If a property is a Multilink, the value
-                must appear in the Multilink 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'
+                'propspec'. See the Permission class for the possible
+                keyword args.
             '''
 
         def addRole(self, **propspec):
index d49dba37303aab48e4ee6482c473a81952d16107..a37347cd83e3a1d7d7b0fb3ef0ac560cd43eccd7 100644 (file)
@@ -2,11 +2,12 @@
 Developing Roundup
 ==================
 
-:Version: $Revision: 1.6 $
+:Version: $Revision: 1.16 $
 
-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`_.
+.. 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::
 
@@ -26,8 +27,8 @@ All development is coordinated through two resources:
 Small Changes
 -------------
 
-Most small changes can be submitted through the Feature tracker, with patches
-attached that give context diffs of the affected source.
+Most small changes can be submitted through the `feature tracker`_, with
+patches attached that give context diffs of the affected source.
 
 
 CVS Access
@@ -43,7 +44,7 @@ CVS stuff:
 
 1. to make a branch (eg. branching for code freeze/release)::
 
-    cvs co -d maint-0-5 -r release-0-5-0-pr1
+    cvs co -d maint-0-5 -r release-0-5-0-pr1 roundup
     cd maint-0-5 
     cvs tag -b maint-0-5
 
@@ -87,7 +88,7 @@ relaxed sometimes). In short:
 
 - 80 column width code
 - 4-space indentations
-- All modules must have a CVS Id line near the top, and a CVS Log at the end
+- All modules must have a CVS Id line near the top
 
 Other project rules:
 
@@ -107,6 +108,327 @@ 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:
+
+ - `poEdit`_ by Vaclav Slavik.  Very nice cross-platform GUI editor.
+
+ - `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.
+
+ - ``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.
+
+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`_
@@ -115,3 +437,39 @@ Back to `Table of Contents`_
 .. _`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
+.. _feature tracker: http://sourceforge.net/tracker/?group_id=31577&atid=402791
+
index a58535cccce155aa7939abae4c9ba63e193cfb6c..ef0552a07e8322c68a8948d316483224b9ef2de5 100644 (file)
@@ -8,20 +8,25 @@ 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
- - doesn't need *any* additional support software - python (2.1+) is enough to
-   get you going
+ - 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
- - the really impatient can try the instant-gratification Demo Mode (``python
-   demo.py``)
+   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, ...
- - keeps a full history of changes to issues with configurable verbosity
+ - 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
@@ -30,17 +35,19 @@ from Ka-Ping Yee in the Software Carpentry "Track" design competition.
    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 types of configuration changes
+   or may veto the creation or modification of items in the database
+ - samples are provided for all manner of configuration changes and
+   customisations
 
 *fast, scalable*
- - with the sqlite, metakit, mysql and postgresql backends, roundup is
+ - with the sqlite, 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)
+   support them (sqlite, 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
@@ -54,8 +61,8 @@ from Ka-Ping Yee in the Software Carpentry "Track" design competition.
    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
+ - may be run using WSGI or through CGI as a normal cgi script, as a
+   stand-alone web server, under mod_python 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
@@ -63,6 +70,12 @@ from Ka-Ping Yee in the Software Carpentry "Track" design competition.
  - 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
@@ -89,6 +102,12 @@ from Ka-Ping Yee in the Software Carpentry "Track" design competition.
  - a variety of sample shell scripts are provided (weekly reports, issue
    generation, ...)
 
+*xmlrpc interface*
+ - simple remote tracker interface with basic HTTP authentication
+ - provides same access to tracker as roundup-admin, but based on
+   XMLRPC calls
+
 .. _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
 
diff --git a/doc/images/edit_issue.png b/doc/images/edit_issue.png
new file mode 100644 (file)
index 0000000..b0f596b
Binary files /dev/null and b/doc/images/edit_issue.png differ
diff --git a/doc/images/index_logged_in.png b/doc/images/index_logged_in.png
new file mode 100644 (file)
index 0000000..f13eac0
Binary files /dev/null and b/doc/images/index_logged_in.png differ
diff --git a/doc/images/index_logged_out.png b/doc/images/index_logged_out.png
new file mode 100644 (file)
index 0000000..839f7ab
Binary files /dev/null and b/doc/images/index_logged_out.png differ
diff --git a/doc/images/my_details.png b/doc/images/my_details.png
new file mode 100644 (file)
index 0000000..511eae8
Binary files /dev/null and b/doc/images/my_details.png differ
diff --git a/doc/images/new_issue.png b/doc/images/new_issue.png
new file mode 100644 (file)
index 0000000..5cf0c7e
Binary files /dev/null and b/doc/images/new_issue.png differ
diff --git a/doc/images/registration.png b/doc/images/registration.png
new file mode 100644 (file)
index 0000000..e21f687
Binary files /dev/null and b/doc/images/registration.png differ
index 41a101f350942e03edd6fcda01760cc3f48f2206..93609bfe0e2ac78d4a32a632d450e523f2520694 100644 (file)
@@ -57,69 +57,135 @@ above avenues of contact are suitable.
 Acknowledgements
 ================
 
-Go Ping, you rock! Also, go Bizar Software and ekit.com for letting me
-implement this system on their time.
+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:
-Eddie Parker,
 Thomas Arendsen Hein,
+Nerijus Baliunas,
 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,
+Brett Cannon,
 Godefroid Chapelle,
 Roch'e Compaan,
+Wil Cooley,
 Joe Cooper,
 Kelley Dagley,
+Bruno Damour,
+Bradley Dean,
+Toby Dickenson,
 Paul F. Dubois,
+Eric Earnst,
+Peter Eisentraut,
+Andrew Eland,
 Jeff Epler,
 Tom Epperly,
+Tamer Fahmy,
+Vickenty Fesunov,
 Hernan Martinez Foffani,
 Stuart D. Gathman,
+Martin Geisler,
 Ajit George,
 Frank Gibbons,
 Johannes Gijsbers,
 Gus Gollings,
+Philipp Gortan,
 Dan Grassi,
+Robin Green,
+Jason Grout,
+Charles Groves,
 Engelbert Gruber,
+Bruce Guenter,
+Tamás Gulácsi,
+Thomas Arendsen Hein,
 Juergen Hermann,
+Tobias Herp,
+Uwe Hoffmann,
+Alex Holkner,
 Tobias Hunger,
+Simon Hyde,
+Paul Jimenez,
 Christophe Kalt,
+Timo Kankare,
 Brian Kelley,
 James Kew,
 Sheila King,
+Michael Klatt,
 Bastian Kleineidam,
 Axel Kollmorgen,
+Cedric Krier,
 Detlef Lannert,
 Andrey Lebedev,
 Henrik Levkowetz,
+David Linke,
+Martin v. Löwis,
 Fredrik Lundh,
+Will Maier,
+Ksenia Marasanova,
+Georges Martin,
 Gordon McMillan,
 John F Meinel Jr,
+Roland Meister,
+Ulrik Mikaelsson,
+John Mitchell,
+Ramiro Morales,
+Toni Mueller,
+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,
+Luke Ross,
 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,
-Klamer Schutte,
+Dan Shidlovsky,
 Joel Shprentz,
 Terrel Shumway,
+Emil Sit,
+Alexander Smishlajev,
 Nathaniel Smith,
+Leonardo Soto,
+Maciej Starzyk,
+Mitchell Surface,
+Anatoly T.,
+Jon C. Thomason
 Mike Thompson,
+Michael Twomey,
+Karl Ulbrich,
+Martin Uzak,
 Darryl VanDorp,
-J Vickroy.
+J Vickroy,
+Timothy J. Warren,
+William (Wilk),
+Tue Wennerberg,
+Matt Wilbert,
+Chris Withers,
+Milan Zamazal.
 
 
 
index 1b94392e41471048e73ca6c4b6aa2f0ed27dc441..f8bdf757461a0f61e4af3d940e4205115558033a 100644 (file)
@@ -2,9 +2,10 @@
 Installing Roundup
 ==================
 
-:Version: $Revision: 1.74 $
+:Version: $Revision: 1.130 $
 
 .. contents::
+   :depth: 2
 
 
 Overview
@@ -21,7 +22,7 @@ Roundup trackers
  `choosing your template`_.
 
 Roundup support code
- Installed into your Python install's lib directory
+ Installed into your Python install's lib directory.
 
 Roundup scripts
  These include the email gateway, the roundup
@@ -31,97 +32,108 @@ Roundup scripts
 Prerequisites
 =============
 
-Roundup requires Python 2.1.3 or newer with a functioning anydbm or
-bsddb module. Download the latest version from http://www.python.org/.
+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.
 
-If you want to use Berkeley DB bsddb3 with Roundup, use version 3.3.0 or
-later. Download the latest version from http://pybsddb.sourceforge.net/.
+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/).
 
-Non-Python2.3 users may need to `install the "CSV" module`_.
 
-Install the "CSV" module
-------------------------
+Optional Components
+===================
+
+You may optionally install and use:
 
-Note: CSV stands for Comma-Separated-Value. These files are used by all
-      manner of programs (eg. spreadsheets) to exchange data.
+Timezone Definitions
+  Full timezone support requires pytz_ module (version 2005i or later)
+  which brings the `Olson tz database`_ into Python.  If pytz_ is not
+  installed, timezones may be specified as numeric hour offsets only.
 
-The "CSV" module is required if you wish to import or export data in the
-tracker, or if you wish to use the online generic class editing facility.
+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).
 
-If you're using a version of Python older than 2.3, then you will need to
-install the "CSV" module from `Object Craft`_. Users of Python2.3 and later
-don't need to. If you have a C compiler installed, then download the source
-and follow their installation instructions (simply ``python setup.py
-install``.)
+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).
 
-If you're on Windows and don't have a C compiler, then you'll need to
-download the pre-compiled ``csv.pyd`` file and install it. To install, just
-copy it to your Python installation in the ``lib\site-packages`` directory.
-For Python 2.2, this would be::
+  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.
 
-   c:\python22\lib\site-packages
+  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.
 
-Once the CSV module is installed, you *must* restart roundup-server if it
-is already running, or the new module won't be detected.
+pyopenssl
+  If pyopenssl_ is installed the roundup-server can be configured
+  to serve trackers over SSL. If you are going to serve roundup via
+  proxy through a server with SSL support (e.g. apache) then this is
+  unnecessary.
 
-If you're on some other platform and don't have a C compiler, you'll need
-to ask for help on the roundup-users mailing list.
+pyme
+  If pyme_ is installed you can configure the mail gateway to perform
+  verification or decryption of incoming OpenPGP MIME messages. When
+  configured, you can require email to be cryptographically signed
+  before roundup will allow it to make modifications to issues.
 
-.. _`Object Craft`: http://object-craft.com.au/
+.. _Xapian: http://www.xapian.org/
+.. _pytz: http://www.python.org/pypi/pytz
+.. _Olson tz database: http://www.twinsun.com/tz/tz-link.htm
+.. _pyopenssl: http://pyopenssl.sourceforge.net
+.. _pyme: http://pyme.sourceforge.net
 
 
 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.
 
-Testing your Python
--------------------
-
-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.
-
 
 For The Really Impatient
 ========================
 
 If you just want to give Roundup a whirl Right Now, then simply run
-``python demo.py``. This will set up a simple demo tracker on your
-machine. 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:
+``roundup-demo``.
+
+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
 ============
 
-: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`_.
-
-Set aside 15-30 minutes. Please make sure you're using a supported version of
-Python -- see `testing your python`_. There's several steps to follow in your
+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
@@ -140,23 +152,23 @@ Basic Installation Steps
 ------------------------
 
 To install the Roundup support code into your Python tree and
-Roundup scripts into /usr/local/bin (substitute that path for whatever is
+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/local/bin``, then specify the preferred location with
-``--install-script``. For example, to install them in
+than ``/usr/bin``, then specify the preferred location with
+``--install-scripts``. 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, take note of the message at the end of installation
-and modify the python path accordingly.
+choose to do this, you may have to change Python's search path (sys.path)
+yourself.
 
 
 Configuring your first tracker
@@ -186,30 +198,33 @@ Configuring your first tracker
           Enter tracker home: /opt/roundup/trackers/support
           Templates: classic
           Select template [classic]: classic
-          Back ends: anydbm, bsddb
+          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 ``MAILHOST``,
-      ``TRACKER_WEB``, ``MAIL_DOMAIN`` and ``ADMIN_EMAIL``. Note that the
-      configuration file uses Python syntax, so almost every value must be
-      ``'quoted'`` using single or double quotes. If you get stuck, and get
-      configuration file errors, then see the `tracker configuration`_ section
-      of the `customisation documentation`_.
+      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::
+      "tracker :: web" variable to::
 
-         TRACKER_WEB = 'http://localhost:8080/support/'
+         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. Note that you may change any of the configuration after
+      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.
 
@@ -220,12 +235,16 @@ Configuring your first tracker
           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
+   new tracker out, assuming "tracker :: web" is set to
    ``'http://localhost:8080/support/'``, run::
 
      roundup-server support=/opt/roundup/trackers/support
@@ -266,38 +285,53 @@ There's several to choose from, each with benefits and limitations:
 Name       Speed       Users   Support
 ========== =========== ===== ==============================
 anydbm     Slowest     Few   Always available
-bsddb      Slow        Few   Usually available
-sqlite     Fastest(*)  Few   Needs install (SQLite_)
-metakit    Fastest(*)  Few   Needs install (metakit_)
+sqlite     Fastest(*)  Few   May need install (PySQLite_)
 postgresql Fast        Many  Needs install/admin (psycopg_)
 mysql      Fast        Many  Needs install/admin (MySQLdb_)
 ========== =========== ===== ==============================
 
-**sqlite** and **metakit**
-  These use the embedded database engines SQLite_ and metakit_ to provide
-  very fast backends. They are not suitable for trackers which will have
-  many simultaneous users.
+**sqlite**
+  This uses the embedded database engine PySQLite_ to provide a very fast
+  backend. This is not suitable for trackers which will have many
+  simultaneous users, but requires much less installation and maintenance
+  effort than more scalable postgresql and mysql backends.
+
+  SQLite is supported via PySQLite versions 1.1.7, 2.1.0 and sqlite3 (the last
+  being bundled with Python 2.5+)
+
+  Installed SQLite should be the latest version available (3.3.8 is known
+  to work, 3.1.3 is known to have problems).
 **postgresql**
   Backend for popular RDBMS PostgreSQL. You must read doc/postgresql.txt for
-  additional installation steps and requirements.
+  additional installation steps and requirements. You must also configure
+  the ``rdbms`` section of your tracker's ``config.ini``.  It is recommended
+  that you use at least version 1.1.21 of psycopg.
 **mysql**
   Backend for popular RDBMS MySQL. You must read doc/mysql.txt for additional
-  installation steps and requirements.
+  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:
+There are five 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`_
+5. `WSGI handler`_
 
 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
@@ -311,7 +345,7 @@ 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.
 
-Note that if your Python isn't install as "python" then you'll need to edit
+If your Python isn't installed 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
@@ -319,31 +353,46 @@ 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`` 
+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.
 
-Copy the ``cgi-bin/roundup.cgi`` file to your web server's ``cgi-bin``
+More information about ISS setup may be found at:
+
+   http://support.microsoft.com/default.aspx?scid=kb%3Ben-us%3B276494
+
+Copy the ``frontends/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'``
+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"
+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, rename 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
+This approach will give you faster response than cgi-bin. You may
 investigate using ProxyPass or similar configuration in apache to have your
 tracker accessed through the same URL as other systems.
 
@@ -368,6 +417,153 @@ 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 you 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 (provided Apache has been set up for SSL of course).
+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>
+
+Notice that the ``/var/db/roundup`` path shown above refers to the directory
+in which the tracker homes are stored. The actual value will thus depend on
+your system.
+
+On Windows the corresponding lines will look similar to these::
+
+    AliasMatch /roundup/(.+)/@@file/(.*) C:/DATA/roundup/$1/html/$2
+    AliasMatch /roundup/([^/]+)/(?!@@file/)(.*) C:/DATA/roundup/$1/dummy.py/$2
+    <Directory C:/DATA/roundup/*>
+    <Directory C:/DATA/roundup/support>
+    <Directory C:/DATA/roundup/devel>
+
+In this example the directory hosting all of the tracker homes is
+``C:\DATA\roundup``. (Notice that you must use forward slashes in paths
+inside the httpd.conf file!)
+
+The URL for accessing these trackers then become:
+`http://<roundupserver>/roundup/support/`` and
+``http://<roundupserver>/roundup/devel/``
+
+Note that in order to use https connections you must set up Apache for secure
+serving with SSL.
+
+WSGI Handler
+~~~~~~~~~~~~
+
+The WSGI handler is quite simple. The following sample code shows how
+to use it::
+
+    from wsgiref.simple_server import make_server
+
+    # obtain the WSGI request dispatcher
+    from roundup.cgi.wsgi_handler import RequestDispatcher
+    tracker_home = 'demo'
+    app = RequestDispatcher(tracker_home)
+
+    httpd = make_server('', 8917, app)
+    httpd.serve_forever()
+
+To test the above you should create a demo tracker with ``python demo.py``.
+Edit the ``config.ini`` to change the web URL to "http://localhost:8917/".
+
 
 Configure an Email Interface
 ----------------------------
@@ -377,33 +573,134 @@ If you don't want to use the email component of Roundup, then remove the
 
 See `platform-specific notes`_ for steps that may be needed on your system.
 
-There are three supported ways to get emailed issues into the
+There are five 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 
+As a mail alias pipe process
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 Set up a mail alias called "issue_tracker" as (include the quote marks):
-"``|/usr/bin/python /usr/local/bin/roundup-mailgw <tracker_home>``"
+"``|/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/local/bin/roundup-mailgw``" and change
-the command to::
+In some installations (e.g. RedHat Linux and Fedora Core) 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@YOUR_DOMAIN_HERE
 
+Be careful that some mail systems (postfix for example) will impost a
+limits on processes they spawn. In particular postfix can set a file size
+limit. *This can cause your Roundup database to become corrupted.*
+
+
+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@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 (this 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::
+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/local/bin/roundup-mailgw /opt/roundup/trackers/support mailbox <mail_spool_file>
+  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"
@@ -412,9 +709,11 @@ 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::
+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/local/bin/roundup-mailgw /opt/roundup/trackers/support pop <pop_spec>
+  0,10,20,30,40,50 * * * * /usr/bin/roundup-mailgw /opt/roundup/trackers/support pop <pop_spec>
 
 where pop_spec is "``username:password@server``" that specifies the roundup
 submission user's POP account name, password and server.
@@ -425,9 +724,10 @@ As a regular job using an IMAP source
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 To retrieve from an IMAP mailbox, use a *cron* entry similar to the
-POP one::
+POP one (substitute ``/usr/bin`` for wherever roundup-mailgw is
+installed)::
 
-  0,10,20,30,40,50 * * * * /usr/local/bin/roundup-mailgw /opt/roundup/trackers/support imap <imap_spec>
+  0,10,20,30,40,50 * * * * /usr/bin/roundup-mailgw /opt/roundup/trackers/support imap <imap_spec>
 
 where imap_spec is "``username:password@server``" that specifies the roundup
 submission user's IMAP account name, password and server. You may
@@ -468,6 +768,12 @@ 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:
 
@@ -477,6 +783,12 @@ responsibility of running roundup. This user:
 4. optionally has no login password so that nobody but the "root" user
    may actually login and play with the roundup setup.
 
+If you're using a Linux system (e.g. Fedora Core) with SELinux enabled,
+you will need to ensure that the db directory has a context that
+permits the web server to modify and create files. If you're using the
+mysql or postgresql backend you may also need to update your policy to
+allow the web server to access the database socket.
+
 
 Additional Language Codecs
 --------------------------
@@ -486,6 +798,15 @@ Korean encodings the you'll need to obtain CJKCodecs from
 http://cjkpython.berlios.de/
 
 
+Public Tracker Considerations
+-----------------------------
+
+If you run a public tracker, you will eventually have to think about
+dealing with spam entered through both the web and mail interfaces.
+
+The `customisation documentation`_ has a simple detector that will block
+a lot of spam attempts. Look for the example "Preventing SPAM".
+
 
 Maintenance
 ===========
@@ -505,7 +826,7 @@ released.
 Further Reading
 ===============
 
-If you intend to use Roundup with anything other than the defualt
+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`_.
 
@@ -558,26 +879,87 @@ 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):
+To have the Roundup web server start up when your machine boots up, there
+are two different methods, the scheduler and installing the service.
+
+
+1. Using the Windows scheduler
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Set up the following 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**
 
-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
+    At System Startup
 
 To have the Roundup mail gateway run periodically to poll a POP email address,
-set the following up in Scheduled Tasks:
+set up the following in Scheduled Tasks:
+
+**Run**
+
+    ``c:\cygwin\bin\bash.exe -c "roundup-mailgw /opt/roundup/trackers/support pop roundup:roundup@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
+
+
+2. Installing the roundup server as a Windows service
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+This is more Windows oriented and will make the Roundup server run as
+soon as the PC starts up without any need for a login or such. It will
+also be available in the normal Windows Administrative Tools.
+
+For this you need first to create a service ini file containing the
+relevant settings.
+
+1. It is created if you execute the following command from within the
+   scripts directory (notice the use of backslashes)::
+
+     roundup-server -S -C <trackersdir>\server.ini -n <servername> -p 8080 -l <trackersdir>\trackerlog.log software=<trackersdir>\Software
 
-Run
- ``c:\cygwin\bin\bash.exe -c "roundup-mailgw /opt/roundup/trackers/support pop roundup:roundup@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
+   where the item ``<trackersdir>`` is replaced with the physical directory
+   that hosts all of your trackers. The ``<servername>`` item is the name
+   of your roundup server PC, such as w2003srv or similar.
+
+2. Next open the now created file ``C:\DATA\roundup\server.ini`` file
+   (if your ``<trackersdir>`` is ``C:\DATA\roundup``).
+   Check the entries for correctness, especially this one::
+
+    [trackers]
+    software = C:\DATA\Roundup\Software
+
+   (this is an example where the tracker is named software and its home is
+   ``C:\DATA\Roundup\Software``)
+
+3. Next give the commands that actually installs and starts the service::
+
+    roundup-server -C C:\DATA\Roundup\server.ini -c install
+    roundup-server -c start
+
+4. Finally open the AdministrativeTools/Services applet and locate the
+   Roundup service entry. Open its properties and change it to start
+   automatically instead of manually.
+
+If you are using Apache as the webserver you might want to use it with
+mod_python instead to serve out Roundup. In that case see the mod_python
+instructions above for details.
 
 
 Sendmail smrsh
@@ -601,9 +983,6 @@ Linux
 
 Make sure you read the instructions under `UNIX environment steps`_.
 
-Python 2.1.1 as shipped with SuSE7.3 might be missing module
-``_weakref``.
-
 
 Solaris
 -------
@@ -613,6 +992,24 @@ 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`_
@@ -631,8 +1028,12 @@ Next: `User Guide`_
 .. _`customising roundup`: customizing.html
 .. _`upgrading document`: upgrading.html
 .. _`administration guide`: admin_guide.html
-.. _sqlite: http://www.hwaci.com/sw/sqlite/
-.. _metakit: http://www.equi4.com/metakit/
-.. _Psycopg: http://initd.org/software/initd/psycopg
-.. _MySQLdb: http://sourceforge.net/projects/mysql-python
 
+
+.. _External hyperlink targets:
+
+.. _apache: http://httpd.apache.org/
+.. _mod_python: http://www.modpython.org/
+.. _MySQLdb: http://sourceforge.net/projects/mysql-python
+.. _Psycopg: http://initd.org/software/initd/psycopg
+.. _pysqlite: http://pysqlite.org/
index 154e4f417a60b37e8c0660c8578f8b6e10dba037..c59e70c0f2170a0b5a70bd65e1e52b8fb702eab8 100644 (file)
@@ -2,7 +2,7 @@
 MySQL Backend
 =============
 
-:version: $Revision: 1.8 $
+:version: $Revision: 1.13 $
 
 This notes detail the MySQL backend for the Roundup issue tracker.
 
@@ -13,16 +13,13 @@ 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
+1. MySQL RDBMS 4.0.18 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)
+   if you have no other choice). If you're running < 4.0.18 (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
 =======================
 
@@ -43,30 +40,10 @@ this:
    you can modify MYSQL_* constants in the file test/test_db.py with 
    the correct values.
 
-Note that the MySQL database should not contain any tables. Tests will not 
+The MySQL database should not contain any tables. Tests will not 
 drop the database with existing data.
 
 
-Additional configuration
-========================
-
-To initialise and use the MySQL database backend, roundup's configuration 
-file (config.py in the tracker's home directory) should have the following
-entries::
-
-    MYSQL_DBHOST = 'localhost'
-    MYSQL_DBUSER = 'rounduptest'
-    MYSQL_DBPASSWORD = 'rounduptest'
-    MYSQL_DBNAME = 'rounduptest'
-    MYSQL_DATABASE = ( MYSQL_DBHOST, MYSQL_DBUSER, MYSQL_DBPASSWORD,
-        MYSQL_DBNAME )
-
-Fill in the first four entries with values for your local MySQL installation 
-before running "roundup-admin initialise".  Use the commands in the `Running the
-MySQL tests` to set up a database if you have privilege, or ask your local
-administrator if not.
-
-
 Showing MySQL who's boss
 ========================
 
@@ -80,3 +57,4 @@ just::
 
 and all will be better (note that on some systems, ``mysql`` is spelt
 ``mysqld``).
+
index e5ac037145e0c6fabb0ebe8667634cfef3b6c72d..5cac5675b8155541b2f80ce5dc3747111ca99ed4 100644 (file)
@@ -147,7 +147,7 @@ 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
+For example, forcing each item into a single keyword
 category is not just suboptimal but counterproductive:
 seekers of that
 item may expect to find it in a different category
@@ -245,7 +245,7 @@ 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.
+or keyword categories relevant to an item.
 
 For Roundup, all items have four properties that are not customizable:
 
@@ -314,13 +314,13 @@ options make it tougher for someone to make a good choice::
     #   superseder = Multilink("issue")
     #   (it also gets the Class properties creation, activity and creator)
     issue = IssueClass(db, "issue", 
-                    assignedto=Link("user"), topic=Multilink("keyword"),
+                    assignedto=Link("user"), keyword=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 **keyword** property places the
+item in an arbitrary number of relevant keyword sets (see
 the section on `Browsing and Searching`_).
 
 The **prority** and **status** values are initially:
@@ -449,11 +449,11 @@ 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
+permit users to monitor particular keywords 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".
+developer could be on the nosy list of the keyword 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.
 
@@ -552,7 +552,7 @@ Each type of property has its own appropriate filtering widget:
   (the filter selects the *intersection* of the sets of items
   associated with the active options)
 
-For a *multilink* property like **topic**,
+For a *multilink* property like **keyword**,
 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
index 4b634ba2980fa78daf617e8b81cbb9c688055dab..3cd32daba9842c4e1eb56b06c5f9a2a4618db72f 100644 (file)
@@ -2,9 +2,8 @@
 PostgreSQL/psycopg Backend
 ==========================
 
-This is notes about PostreSQL backend based on the psycopg adapter for
-roundup issue tracker.
-
+This are notes about PostqreSQL backend based on the psycopg adapter for
+Roundup issue tracker.
 
 Prerequisites
 =============
@@ -15,7 +14,14 @@ additionally install:
 1. PostgreSQL 7.x - http://www.postgresql.org/
 
 2. The psycopg python interface to PostgreSQL:
-   http://initd.org/software/initd/psycopg
+
+     http://initd.org/software/initd/psycopg
+
+   It is recommended that you use at least version 1.1.21
+
+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
@@ -29,31 +35,9 @@ you wish to test against a different database.
 The test database will be called "rounduptest".
 
 
-Additional configuration
-========================
-
-To initialise and use PostgreSQL database roundup's configuration file
-(config.py in the tracker's home directory) should be appended with the
-following constants (substituting real values, obviously)::
-
-    POSTGRESQL_DATABASE = {'database': 'rounduptest'}
-
-if not local, or a different user is to be used, then more information may
-be supplied::
-
-    POSTGRESQL_DATABASE = {
-        'host': 'localhost', 'port': 5432,
-        'database': 'roundup'
-        'user': 'roundup', 'password': 'roundup',
-    }
-
-Also note that you can leave some values out of
-``POSTGRESQL_DATABASE``: 'host' and 'port' are not necessary when
-connecting to a local database and 'password'
-is optional if postgres trusts local connections. The user specified in
-``user`` must have rights to create a new database and to
-connect to the "template1" database, used while initializing roundup.
+Credit
+======
 
-    Have fun with psycopg,
-    Federico Di Gregorio <fog@initd.org>
+The postgresql backend was originally submitted by Federico Di Gregorio
+<fog@initd.org>
 
diff --git a/doc/roundup-demo.1 b/doc/roundup-demo.1
new file mode 100644 (file)
index 0000000..caa6684
--- /dev/null
@@ -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).
+.SH 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@users.sourceforge.net>.
index 593cd47def25bf77cdc664f9b641b9a299d1cc61..5802fb75c6e37e1bfc395774add511c9cb5c8ef6 100644 (file)
@@ -34,6 +34,13 @@ emptied once all messages have been successfully handled. The file is
 specified as:
  \fImailbox /path/to/mailbox\fP
 
+In all of the following the username and password can be stored in a
+~/.netrc file. In this case only the server name need be specified on
+the command-line.
+
+The username and/or password will be prompted for if not supplied on
+the command-line or in ~/.netrc.
+
 \fBPOP\fP
 .br
 In the third case, the gateway reads all messages from the POP server
@@ -45,8 +52,7 @@ The username and password may be omitted:
  \fIpop username@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.
+are both valid.
 
 \fBAPOP\fP
 Same as POP, but using Authenticated POP:
index d12679fd11110e9824a212489416c60448b0fc64..8b9267364642fc92ffa2ee0dd77926c003e3dd26 100644 (file)
@@ -1,34 +1,58 @@
-.TH ROUNDUP-SERVER 1 "24 January 2003"
+.TH ROUNDUP-SERVER 1 "27 July 2004"
 .SH NAME
-roundup-server \- start roundup server
+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
+Sets the host name.
 .TP
 \fB-p\fP \fIport\fP
-sets the port to listen on
+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)
+Sets a filename to log to (instead of stdout). This is required if the -d
+option is used.
 .TP
-\fB-d\fP \fIfile\fP
-daemonize, and write the server's PID to the nominated file
+\fB-i\fP \fIfile\fP
+Sets a filename to use as a template for generating the tracker index page.
+The variable "trackers" is available to the template and is a dict of all
+configured trackers.
+.TP
+\fB-s\fP
+Enables to use of SSL.
+.TP
+\fB-e\fP \fIfile\fP
+Sets a filename containing the PEM file to use for SSL. If left blank, a
+temporary self-signed certificate will be 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.
+Sets the tracker home(s) to use. The \fBname\fP variable is how the tracker is
+identified in the URL (it's the first part of the URL path). The \fItracker
+home\fP variable 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 EXAMPLES
+.TP
+.B roundup-server -p 9000 bugs=/var/tracker reqs=/home/roundup/group1
+Start the server on port \fB9000\fP serving two trackers; one under
+\fB/bugs\fP and one under \fB/reqs\fP.
+
+.SH CONFIGURATION FILE
+See the "admin_guide" in the Roundup "doc" directory.
 .SH AUTHOR
 This manpage was written by Bastian Kleineidam
 <calvin@debian.org> for the Debian distribution of roundup.
diff --git a/doc/roundup-server.ini.example b/doc/roundup-server.ini.example
new file mode 100644 (file)
index 0000000..dbdfe77
--- /dev/null
@@ -0,0 +1,19 @@
+; This is a sample configuration file for roundup-server. See the
+; admin_guide for information about its contents.
+[main]
+port = 8080
+;hostname = 
+;user = 
+;group = 
+;log_ip = yes
+;pidfile = 
+;logfile = 
+;template =
+;ssl = no
+;pem =
+
+
+; Add one of these per tracker being served
+[trackers]
+home = /path/to/tracker
+
index 021bf7ddae794808633e017a32f90252c1785464..c66422953ad527f1bd939a3cf3cac56ff35f202d 100644 (file)
@@ -2,15 +2,15 @@
 Roundup Tracker Templates
 =========================
 
-:Version: $Revision: 1.1 $
+: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 
+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.
@@ -24,11 +24,12 @@ The template loading looks in four places to find the templates:
 
 Templates contain:
 
-- modules __init__.py, dbinit.py, config.py, interfaces.py
-- directories html and detectors (with appropriate contents)
-- TEMPLATE-INFO.txt which is our template "marker" file, which contains 
-  the name of the template,  a description of the template and its 
-  intended audience.
+- 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::
 
index f230c88888b7d246c5b33d116911898e5ca73273..2636b6297b31dfe6d67722531dd315220b94a812 100644 (file)
@@ -6,8 +6,458 @@ 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.4.2 to 1.4.3
+=============================
+
+If you are using the MySQL backend you will need to replace some indexes
+that may have been created by version 1.4.2.
+
+You should to access your MySQL database directly and remove any indexes
+with a name ending in "_key_retired_idx". You should then re-add them with
+the same spec except the key column name needs a size. So an index on
+"_user (__retired, _name)" should become "_user (__retired, _name(255))".
+
+
+Migrating from 1.4.x to 1.4.2
+=============================
+
+You should run the "roundup-admin migrate" command for your tracker once
+you've installed the latest codebase. 
+
+Do this before you use the web, command-line or mail interface and before
+any users access the tracker.
+
+This command will respond with either "Tracker updated" (if you've not
+previously run it on an RDBMS backend) or "No migration action required"
+(if you have run it, or have used another interface to the tracker,
+or are using anydbm).
+
+It's safe to run this even if it's not required, so just get into the
+habit.
+
+
+Migrating from 1.3.3 to 1.4.0
+=============================
+
+Value of the "refwd_re" tracker configuration option (section "mailgw")
+is treated as UTF-8 string.  In previous versions, it was ISO8859-1.
+
+If you have running trackers based on the classic template, please
+update the messagesummary detector as follows::
+
+    --- detectors/messagesummary.py 17 Apr 2003 03:26:38 -0000      1.1
+    +++ detectors/messagesummary.py 3 Apr 2007 06:47:21 -0000       1.2
+    @@ -8,7 +8,7 @@
+     if newvalues.has_key('summary') or not newvalues.has_key('content'):
+         return
+
+    -    summary, content = parseContent(newvalues['content'], 1, 1)
+    +    summary, content = parseContent(newvalues['content'], config=db.config)
+     newvalues['summary'] = summary
+
+In the latest version we have added some database indexes to the
+SQL-backends (mysql, postgresql, sqlite) for speeding up building the
+roundup-index for full-text search. We recommend that you create the
+following database indexes on the database by hand::
+
+ CREATE INDEX words_by_id ON __words (_textid)
+ CREATE UNIQUE INDEX __textids_by_props ON __textids (_class, _itemid, _prop)
+
+Migrating from 1.2.x to 1.3.0
+=============================
+
+1.3.0 Web interface changes
+---------------------------
+
+Some of the HTML files in the "classic" and "minimal" tracker templates
+were changed to fix some bugs and clean them up. You may wish to compare
+them to the HTML files in your tracker and apply any changes.
+
+
+Migrating from 1.1.2 to 1.2.0
+=============================
+
+1.2.0 Sorting and grouping by multiple properties
+-------------------------------------------------
+
+Starting with this version, sorting and grouping by multiple properties
+is possible. This means that request.sort and request.group are now
+lists. This is reflected in several places:
+
+ * ``renderWith`` now has list attributes for ``sort`` and ``group``,
+   where you previously wrote::
+   
+    renderWith(... sort=('-', 'activity'), group=('+', 'priority')
+
+   you write now::
+
+    renderWith(... sort=[('-', 'activity')], group=[('+', 'priority')]
+
+ * In templates that permit to edit sorting/grouping, request.sort and
+   request.group are (possibly empty) lists. You can now sort and group
+   by multiple attributes. For an example, see the classic template. You
+   may want search for the variable ``n_sort`` which can be set to the
+   number of sort/group properties.
+
+ * Templates that diplay new headlines for each group of items with
+   equal group properties can now use the modified ``batch.propchanged``
+   method that can take several properties which are checked for
+   changes. See the example in the classic template which makes use of
+   ``batch.propchanged``.
+
+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. If you have code in
+an ``interfaces.py`` file you should move it. See the `customisation
+documentation`_ for information about how extensions are now written.
+Note that some older trackers may use ``interfaces.py`` to customise the
+mail gateway behaviour. You will need to keep your ``interfaces.py`` file
+if this is the case.
+
+
+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
 =========================
@@ -40,8 +490,10 @@ add::
 0.7.0 Getting the current user id
 ---------------------------------
 
-Removed Database.curuserid attribute. Any code referencing this attribute
-should be replaced with a call to Database.getuid().
+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
@@ -59,7 +511,7 @@ as::
 
   :action :required :template :remove:messages ...
 
-should become:
+should become::
 
   @action @required @template @remove@messages ...
 
@@ -68,6 +520,17 @@ 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
 =============================
 
@@ -945,4 +1408,4 @@ copy.
 
 .. _`customisation documentation`: customizing.html
 .. _`security documentation`: security.html
-.. _`administration guide`: admin.html
+.. _`administration guide`: admin_guide.html
index 474dcdc222e931fbeed880977d934f5c5213a53a..5879370b29e88c87c8d5c1da94cc0e16b901eb18 100644 (file)
@@ -2,13 +2,14 @@
 User Guide
 ==========
 
-:Version: $Revision: 1.27 $
+:Version: $Revision: 1.38 $
 
 .. contents::
 
-Note: 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.
+.. 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
@@ -20,7 +21,7 @@ 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
+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".
@@ -43,30 +44,55 @@ You may access your tracker through one of three ways:
 3. using the `command line tool`_.
 
 The last is usually only used by administrators. Most users will use the
-web and email interfaces. All three are explained below.
+web and e-mail interfaces. All three are explained below.
 
 
 Issue life cycles in Roundup
 ----------------------------
 
-New issues may be submitted via the web or email.
+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.
+are not "resolved".
 
 If an issue is closed, and a new message is received then it'll be
 reopened to the state of "chatting".
 
+The full set of **prority** and **status** values are:
+
+=========== =====================================
+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
+============= =====================================
+
 
 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 email interface and even the command-line
+for searching issues, the e-mail interface and even the command-line
 administration tool.
 
 
@@ -86,7 +112,7 @@ These fields take a value which indicates "yes"/"no", "true"/"false",
 Constrained (link and multilink) properties
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-Fields like "Assigned To" and "Topics" hold references to items in other
+Fields like "Assigned To" and "Keywords" 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
@@ -104,20 +130,47 @@ not set. For example, the following searches on the issues:
   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,email interface``
-  match issues with the keyword "web interface" or "email interface" in
-  their topic list
-``topic=-1``
-  match issues with no topics set
+``keyword=user interface``
+  match issues with the keyword "user interface" in their keyword list
+``keyword=web interface,e-mail interface``
+  match issues with the keyword "web interface" or "e-mail interface" in
+  their keyword list
+``keyword=-1``
+  match issues with no keywords set
 
 
 Date properties
 ~~~~~~~~~~~~~~~
 
-Some fields in the search page (e.g. "Activity" or "Creation date") hold
-dates.  A plain date entered as a search field will match that date
+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:
 
@@ -137,43 +190,45 @@ 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 Sat Mar  8 22:07:48
-2003)::
-
-    >>> Range("from 2-12 to 4-2")
-    <Range from 2003-02-12.00:00:00 to 2003-04-02.00:00:00>
-    
-    >>> Range("FROM 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>
-
-    >>> Range("2003")
-    <Range from 2003-01-01.00:00:00 to 2003-12-31.23:59:59>
-
-    >>> Range("2003-04")
-    <Range from 2003-04-01.00:00:00 to 2003-04-30.23:59:59>
+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
 ~~~~~~~~~~~~~~~~~~~
 
-XXX explain...
+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).
 
-When searching on interval properties use the same syntax as for dates.
+-   "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
@@ -187,9 +242,10 @@ 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.
+.. 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:
 
@@ -205,26 +261,24 @@ 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
+.. image:: images/index_logged_out.png
 
-The screen is divided up into three sections:
+The screen is divided up into three sections. There's a title which tells
+you where you are, a sidebar which contains useful navigation tools and a
+body which usually displays either a list of items or a single item from
+the tracker.
 
-.. img: images/page_layout.png
+You may either register or log in. Registration takes you to:
 
-you may either register or log in. Registration takes you to:
+.. image:: images/registration.png
 
-.. img: images/registration.png
+Once you're logged in, the sidebar changes to:
 
-Once you're logged in, the screen changes slightly to:
+.. image:: images/index_logged_in.png
 
-.. img: images/index_logged_in.png
+You can now get to your "My Details" page:
 
-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.
+.. image:: images/my_details.png
 
 
 Display, edit or entry of an item
@@ -233,17 +287,12 @@ 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:
+.. image:: images/new_issue.png
 
-.. img: images/new_issue_created.png
+Editing an issue uses the same form, though now you'll see attached files
+and messages, and the issue history at the bottom of the page:
 
-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
+.. image:: images/edit_issue.png
 
 
 Searching Page
@@ -278,30 +327,34 @@ Under the covers
 The searching page converts your selections into the following
 arguments:
 
-========== =============================================================
-Argument   Description
-========== =============================================================
-:sort      sort by prop name, optionally preceeded with '-' to give
-           descending or nothing for ascending sorting.
-:group     group by prop name, optionally preceeded with '-' or to sort
-           in descending or nothing for ascending order.
-:filter    selects which props should be displayed in the filter
-           section. Default is all.           
-:columns   selects the columns that should be displayed. Default is
-           all.                     
-propname   selects the values the item properties given by propname must
-           have (very basic search/filter).
-========== =============================================================
+============ =============================================================
+Argument     Description
+============ =============================================================
+@sort        sort by prop name, optionally preceeded with '-' to give
+             descending or nothing for ascending sorting. The sort
+             argument can have several props separated with comma.
+@group       group by prop name, optionally preceeded with '-' or to sort
+             in descending or nothing for ascending order. The group
+             argument can have several props separated with comma.
+@columns     selects the columns that should be displayed. Default is
+             all.                     
+@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).
+@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
+        keyword=security,ui&
+        @group=priority,-status&
+        @sort=-activity&
+        @filters=status,keyword&
+        @columns=title,status,fixer
 
 
 Access Controls
@@ -321,14 +374,23 @@ 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 email web access
+- don't give users who register through e-mail web access
 - let some users edit the details of all users
 
 
 E-Mail Gateway
 ==============
 
-E-mail sent to Roundup is examined for several pieces of information:
+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
@@ -346,9 +408,9 @@ The subject line of the incoming message is examined to find one of:
 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. Note that any "re:" or "fwd:"
-prefixes are stripped off the subject line before we start looking for
-real information.
+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
@@ -415,13 +477,18 @@ Automatic Properties
 Sender identification
 ---------------------
 
-If the sender of an email is unknown to Roundup (looking up both user
-primary email 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@domain" in their email address. Their password will be
+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
+may be created, depending on tracker configuration (see the `Admin
+Guide`_ section "Users and Security" for configuration details.)
+
+.. _`Admin Guide`: admin_guide.html
+
+The new user will have their username set to the "user" part of
+"user@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. Note that some sites don't allow web access by users
-who register via email like this.
+have it changed. Some sites don't allow web access by users who register
+via e-mail like this.
 
 
 E-Mail Message Content
@@ -520,9 +587,9 @@ 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.
+an e-mail message.
 
-It can let you set the type of the message on a per email address basis.
+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
@@ -662,8 +729,8 @@ 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.
+With version 0.6.0 or newer of roundup (which introduced support for
+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
index e997918ae8b3a442004ee6a9b157e3a091ecfee2..148665910f517142378acc30878f321de01442a0 100644 (file)
@@ -155,26 +155,16 @@ text/html. This is done with::
 if you were returning a PNG image.
 
 
-Added CSV export action
------------------------
-
-A new action has been added which exports the current index page or search
-result as a comma-separated-value (CSV) file.
-
-To use it, add this to your "index" templates::
-
-  <a tal:attributes="href python:request.indexargs_url('issue',
-      {'@action':'export_csv'})">Download as CSV</a>
-
-Making sure that the ``'issue'`` part matches the class name of the page
-you're editing.
-
 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
 ---------------------------
@@ -221,7 +211,7 @@ Quoting of URLs and HTML
 ------------------------
 
 Templates that wish to offer file downloads may now use a new
-``download_url`` method:
+``download_url`` method::
 
  <tr tal:repeat="file context/files">
   <td>
@@ -238,6 +228,33 @@ 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
 ===============
 
@@ -285,6 +302,10 @@ In addition, the ``IssueClass`` methods ``nosymessage()`` and
 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
 ----------------------------------
@@ -293,6 +314,23 @@ 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@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@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
 ======================
 
diff --git a/doc/whatsnew-0.8.txt b/doc/whatsnew-0.8.txt
new file mode 100644 (file)
index 0000000..1a1c4f8
--- /dev/null
@@ -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
diff --git a/doc/xmlrpc.txt b/doc/xmlrpc.txt
new file mode 100644 (file)
index 0000000..c071992
--- /dev/null
@@ -0,0 +1,88 @@
+=========================
+XML-RPC access to Roundup
+=========================
+
+.. contents::
+
+Introduction
+------------
+Version 1.4 of Roundup includes an XML-RPC frontend. Some installations find
+that roundup-admins requirement of local access to the tracker instance
+limiting. The XML-RPC frontend provides the ability to execute a limited subset
+of commands similar to those found in roundup-admin from remote machines. 
+
+roundup-xmlrpc-server
+---------------------
+The Roundup XML-RPC server must be started before remote clients can access the
+tracker via XML-RPC. ``roundup-xmlrpc-server`` is installed in the scripts
+directory alongside ``roundup-server`` and roundup-admin``. When invoked, the
+location of the tracker instance must be specified.
+
+       roundup-xmlrpc-server -i ``/path/to/tracker``
+
+The default port is ``8000``. An alternative port can be specified with the
+``--port`` switch.
+
+security consideration
+======================
+Note that the current ``roundup-xmlrpc-server`` implementation does not
+support SSL. This means that usernames and passwords will be passed in
+cleartext unless the server is being proxied behind another server (such as
+Apache or lighttpd) that provide SSL.
+
+client API
+----------
+The server currently implements four methods. Each method requires that the
+user provide a username and password in the HTTP authorization header in order
+to authenticate the request against the tracker.
+
+======= ====================================================================
+Command Description
+======= ====================================================================
+list    arguments: *classname, [property_name]*
+
+        List all elements of a given ``classname``. If ``property_name`` is
+        specified, that is the property that will be displayed for each
+        element. If ``property_name`` is not specified the default label
+        property will be used.
+
+display arguments: *designator, [property_1, ..., property_N]*
+
+        Display a single item in the tracker as specified by ``designator``
+        (e.g. issue20 or user5). The default is to display all properties
+        for the item. Alternatively, a list of properties to display can be
+        specified.
+
+create  arguments: *classname, arg_1 ... arg_N*
+
+        Create a new instance of ``classname`` with ``arg_1`` through
+        ``arg_N`` as the values of the new instance. The arguments are
+        name=value pairs (e.g. ``status='3'``).
+
+set     arguments: *designator, arg_1 ... arg_N*
+
+        Set the values of an existing item in the tracker as specified by
+        ``designator``. The new values are specified in ``arg_1`` through
+        ``arg_N``. The arguments are name=value pairs (e.g. ``status='3'``).
+======= ====================================================================
+
+sample python client
+====================
+::
+
+        >>> import xmlrpclib
+        >>> roundup_server = xmlrpclib.ServerProxy('http://username:password@localhost:8000')
+        >>> roundup_server.list('user')
+        ['admin', 'anonymous', 'demo']
+        >>> roundup_server.list('issue', 'id')
+        ['1']
+        >>> roundup_server.display('issue1')
+        {'assignedto' : None, 'files' : [], 'title' = 'yes, ..... }
+        >>> roundup_server.display('issue1', 'priority', 'status')
+        {'priority' : '1', 'status' : '2'}
+        >>> roundup_server.set('issue1', 'status=3')
+        >>> roundup_server.display('issue1', 'status')
+        {'status' : '3' }
+        >>> roundup_server.create('issue', "title='another bug'", "status=2")
+        '2'
+
index 9ed50e483dd5c77942792b2eebcb851282b187bc..4975dc73eb48d4d6fa7d52064207cb666d72e00f 100644 (file)
@@ -1,8 +1,10 @@
 This directory contains alternate front-ends for Roundup.
 
-Zope - ZRoundup
----------------
+roundup.cgi
+   This is a cgi-bin script.
 
-This installs as a regular Zope product. See Roundup's doc/installation.txt
-for more info.
+ZRoundup
+   This is a simple Zope frontend that installs as a regular Zope product.
 
+See Roundup's doc/installation.txt for more info on installing these
+frontends.
index 5b4077203cf5cdadaeaa910b26d467981000ab3d..e2a7d4b826475d92013bd71133bba8e63d007270 100644 (file)
@@ -14,7 +14,7 @@
 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 # 
-# $Id: ZRoundup.py,v 1.17 2003-11-12 01:00:58 richard Exp $
+# $Id: ZRoundup.py,v 1.23 2008-02-07 01:03:39 richard Exp $
 #
 ''' ZRoundup module - exposes the roundup web interface to Zope
 
@@ -40,7 +40,7 @@ from AccessControl import ModuleSecurityInfo
 modulesecurity = ModuleSecurityInfo()
 
 import roundup.instance
-from roundup.cgi.client import NotFound
+from roundup.cgi import client
 
 modulesecurity.declareProtected('View management screens',
     'manage_addZRoundupForm')
@@ -67,6 +67,11 @@ class RequestWrapper:
     def end_headers(self):
         # not needed - the RESPONSE object handles this internally on write()
         pass
+    def start_response(self, headers, response):
+        self.send_response(response)
+        for key, value in headers:
+            self.send_header(key, value)
+        self.end_headers()
 
 class FormItem:
     '''Make a Zope form item look like a cgi.py one
@@ -75,19 +80,34 @@ class FormItem:
         self.value = value
         if hasattr(self.value, 'filename'):
             self.filename = self.value.filename
-            self.file = self.value
+            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
+        self.__form = form
     def __getitem__(self, item):
-        return FormItem(self.form[item])
+        entry = self.__form[item]
+        if isinstance(entry, type([])):
+            entry = map(FormItem, entry)
+        else:
+            entry = FormItem(entry)
+        return entry
+    def __iter__(self):
+        return iter(self.__form)
+    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)
+        return self.__form.has_key(item)
     def keys(self):
-        return self.form.keys()
+        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
@@ -118,7 +138,7 @@ class ZRoundup(Item, PropertyManager, Implicit, Persistent):
     def roundup_opendb(self):
         '''Open the roundup instance database for a transaction.
         '''
-        instance = roundup.instance.open(self.instance_home)
+        tracker = roundup.instance.open(self.instance_home)
         request = RequestWrapper(self.REQUEST['RESPONSE'])
         env = self.REQUEST.environ
 
@@ -138,7 +158,9 @@ class ZRoundup(Item, PropertyManager, Implicit, Persistent):
             env['TRACKER_NAME'] = path_components[-1]
 
         form = FormWrapper(self.REQUEST.form)
-        return instance.Client(instance, request, env, 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):
@@ -187,7 +209,7 @@ class PathElement(Item, Implicit):
             # and call roundup to do something 
             client.main()
             return ''
-        except NotFound:
+        except client.NotFound:
             raise 'NotFound', REQUEST.URL
             pass
         except:
index 1d2e272c396cff01cb60384111ea270e58ba034b..5c86feed3e9ab07917fd9484ae970ff8b8a8c7e3 100644 (file)
@@ -14,9 +14,9 @@
 # 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 $
+# $Id: __init__.py,v 1.5 2006-08-11 00:04:29 richard Exp $
 #
-__version__='1.0'
+__version__='1.1'
 
 import os
 # figure where ZRoundup is installed
@@ -37,12 +37,13 @@ if here is None:
             raise ValueError, "Can't determine where ZRoundup is installed"
 
 # product initialisation
-import ZRoundup
+from ZRoundup import ZRoundup, manage_addZRoundupForm, manage_addZRoundup
 def initialize(context):
     context.registerClass(
-        ZRoundup, meta_type = 'Z Roundup',
+        ZRoundup,
+        meta_type = 'Z Roundup',
         constructors = (
-            ZRoundup.manage_addZRoundupForm, ZRoundup.manage_addZRoundup
+            manage_addZRoundupForm, manage_addZRoundup
         )
     )
 
diff --git a/frontends/ZRoundup/refresh.txt b/frontends/ZRoundup/refresh.txt
new file mode 100644 (file)
index 0000000..3860525
--- /dev/null
@@ -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.
diff --git a/frontends/roundup.cgi b/frontends/roundup.cgi
new file mode 100755 (executable)
index 0000000..d9e6213
--- /dev/null
@@ -0,0 +1,234 @@
+#!/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.2 2006-12-11 23:36:15 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")
+    def start_response(self, headers, response):
+        self.send_response(response)
+        for key, value in headers:
+            self.send_header(key, value)
+        self.end_headers()
+
+#
+# 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
diff --git a/locale/.cvsignore b/locale/.cvsignore
new file mode 100644 (file)
index 0000000..c019cda
--- /dev/null
@@ -0,0 +1,5 @@
+*.mo
+*.bak
+*.swp
+*.tmp
+*.poedit
\ No newline at end of file
diff --git a/locale/GNUmakefile b/locale/GNUmakefile
new file mode 100644 (file)
index 0000000..400a16d
--- /dev/null
@@ -0,0 +1,57 @@
+# Extract translatable strings from Roundup sources,
+# update and compile all existing translations
+#
+# $Id: GNUmakefile,v 1.11 2006-11-16 14:14:42 a1s Exp $
+
+# tool locations
+XPOT ?= xpot
+MSGFMT ?= msgfmt
+MSGMERGE ?= msgmerge
+XGETTEXT ?= xgettext
+PYTHON ?= python
+
+TEMPLATE=roundup.pot
+
+PACKAGES=$(shell find ../roundup ../templates -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@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 $@ $<
diff --git a/locale/de.po b/locale/de.po
new file mode 100644 (file)
index 0000000..962e15f
--- /dev/null
@@ -0,0 +1,4143 @@
+# German message file for Roundup Issue Tracker
+# Initial work by Stefan Niederhauser <stefan.niederhauser@unibas.ch>, 2004.
+# updated by Toni Mueller <toni@debian.org>
+# updated by Tobias Herp <tobias.herp@gmx.de>
+#
+# $Id: de.po,v 1.9 2008-08-19 01:51:10 richard Exp $
+#
+# roundup.pot revision 1.8
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Roundup 1.2.1\n"
+"Report-Msgid-Bugs-To: roundup-devel@lists.sourceforge.net\n"
+"POT-Creation-Date: 2004-12-08 10:25+0200\n"
+"PO-Revision-Date: 2008-03-19 11:34+0100\n"
+"Last-Translator: Toni Mueller <toni@debian.org>\n"
+"Language-Team: German Translators <roundup-devel@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:96 ../roundup/admin.py:100 ../roundup/admin.py:96:100
+# ../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:113
+#: ../roundup/admin.py:111
+#, python-format
+msgid ""
+"Problem: %(message)s\n"
+"\n"
+msgstr ""
+"Problem: %(message)s\n"
+"\n"
+
+#: ../roundup/admin.py:114
+#: ../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"
+" -V                -- be verbose when importing\n"
+" -v                -- report Roundup and Python versions (and quit)\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[:Passwort] für das Ausführen von Befehlen\n"
+" -d                -- lange Bezeichner statt Klassen-Ids anzeigen\n"
+" -c                -- Komma-getrennte Listenausgabe (CSV).\n"
+"                      Analog zu '-S \",\"'.\n"
+" -S <Zeichenkette> -- Trennzeichen bei der Listenausgabe.\n"
+" -s                -- Leerzeichen als Trennzeichen verwenden.\n"
+"                      Analog zu '-S \" \"'.\n"
+" -V                -- ausführliche Ausgaben (\"verbose\") beim Import\n"
+" -v                -- Roundup- und Python-Version ausgeben (und beenden)\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 Hilfen anzeigen\n"
+
+#: ../roundup/admin.py:141
+#: ../roundup/admin.py:137
+msgid "Commands:"
+msgstr "Befehle:"
+
+#: ../roundup/admin.py:148
+#: ../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:178
+#: ../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 - außer 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 Kommata getrennt\n"
+"   ausgegeben. Bei der Eingabe können Bezeichner oder Schlüssel\n"
+"   mit Kommata getrennt eingegeben werden.\n"
+"\n"
+"Falls Eigenschaften Leerzeichen enthalten, müssen die Werte in\n"
+"\"Anführungszeichen\" eingeschlossen werden. Leerzeichen 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"
+"           \"Alfons'\" Welt    (2 Werte: Alfons', Welt)\n"
+"           Alfons\\' Welt      (2 Werte: Alfons', 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"
+"bzw. 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 Option -c) kommagetrennt\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:241
+#: ../roundup/admin.py:237
+#, python-format
+msgid "%s:"
+msgstr "%s:"
+
+#: ../roundup/admin.py:246
+#: ../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:269
+#: ../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:346 ../roundup/admin.py:402 ../roundup/admin.py:346:402
+# ../roundup/admin.py:336 :382
+#: ../roundup/admin.py:337 ../roundup/admin.py:386
+msgid "Templates:"
+msgstr "Vorlagen:"
+
+#: ../roundup/admin.py:349 ../roundup/admin.py:413 ../roundup/admin.py:349:413
+# ../roundup/admin.py:339 :393
+#: ../roundup/admin.py:340 ../roundup/admin.py:397
+msgid "Back ends:"
+msgstr "Datenbanken:"
+
+#: ../roundup/admin.py:352
+#: ../roundup/admin.py:343
+msgid ""
+"Usage: install [template [backend [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 and backend may be specified on the command-line\n"
+"        as arguments, in that order.\n"
+"\n"
+"        Command line arguments following the backend allows you to\n"
+"        pass initial values 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 ""
+"Verwendung: install [Vorlage [Datenbanktyp [Admin-Paßwort [opt=wert[,...]]]]]\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 auch die Vorlage, den Datenbanktyp und das Passwort\n"
+"        in dieser Reihenfolge auf der Kommandozeile angegen.\n"
+"\n"
+"        Das letzte Argument erlaubt die Angabe von Konfigurations-Optionen.\n"
+"        So wird zum Beispiel durch Angabe von\n"
+"           \"web_http_auth=no,rdbms_user=dinsdale\"\n"
+"        die Option http_auth in der Sektion [web] ausgeschaltet und der\n"
+"        Name des Datenbank-Benutzers in der Sektion [rdbms] geändert.\n"
+"        Vorsicht bitte mit Leerzeichen! Wenn sie Leerzeichen angeben müssen,\n"
+"        schließen Sie das ganze Argument in Gänsefüßchen ein.\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:375 ../roundup/admin.py:472 ../roundup/admin.py:533
+#: ../roundup/admin.py:612 ../roundup/admin.py:663 ../roundup/admin.py:721
+#: ../roundup/admin.py:742 ../roundup/admin.py:770 ../roundup/admin.py:842
+#: ../roundup/admin.py:909 ../roundup/admin.py:980 ../roundup/admin.py:1030
+#: ../roundup/admin.py:1053 ../roundup/admin.py:1084 ../roundup/admin.py:1180
+#: ../roundup/admin.py:1253 ../roundup/admin.py:375:472 :1030:1053 :1084:1180
+#: :1253 :533:612 :663:721 :742:770 :842:909 :980
+# ../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:381
+#: ../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:389
+#: ../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:404
+#: ../roundup/admin.py:388
+msgid "Select template [classic]: "
+msgstr "Template auswählen [classic]:"
+
+#: ../roundup/admin.py:415
+#: ../roundup/admin.py:399
+msgid "Select backend [anydbm]: "
+msgstr "Datenbank auswählen [anydbm]"
+
+#: ../roundup/admin.py:425
+#: ../roundup/admin.py:419
+#, python-format
+msgid "Error in configuration settings: \"%s\""
+msgstr ""
+"Fehler in der Konfiguration: \"%s\""
+
+#: ../roundup/admin.py:434
+#: ../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:444
+#: ../roundup/admin.py:417
+msgid " ... at a minimum, you must set following options:"
+msgstr " ... passen sie zumindest folgende Optionen an:"
+
+#: ../roundup/admin.py:449
+#: ../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"
+"\n"
+" You MUST run the \"roundup-admin initialise\" command once you've performed\n"
+" the above steps.\n"
+"---------------------------------------------------------------------------\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 enthält ein eigenes Kapitel über Anpassungen.\n"
+"\n"
+" Anschließend MÜSSEN Sie \"roundup-admin initialise\" ausführen.\n"
+"---------------------------------------------------------------------------\n"
+
+#: ../roundup/admin.py:467
+#: ../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"
+"        Standard-Werten in die Datei <filename>.\n"
+"        "
+
+#. password
+#: ../roundup/admin.py:477
+#: ../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 [Admin-Paßwort]\n"
+"        Initialisieren eines neuen Roundup-Trackers.\n"
+"\n"
+"        Der Administrator-Benutzer wird eingerichtet.\n"
+"\n"
+"        Die Funktion dbinit.init() wird aufgerufen\n"
+"        "
+
+#: ../roundup/admin.py:460
+msgid "Admin Password: "
+msgstr "Administratorpasswort: "
+
+#: ../roundup/admin.py:461
+msgid "       Confirm: "
+msgstr "  Wiederholen: "
+
+#: ../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:526
+#: ../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:566 ../roundup/admin.py:581 ../roundup/admin.py:566:581
+# ../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; die Option -d wird "
+"deshalb hier nicht ausgewertet."
+
+#: ../roundup/admin.py:589 ../roundup/admin.py:991 ../roundup/admin.py:1042
+#: ../roundup/admin.py:1065 ../roundup/admin.py:589:991 :1042:1065
+# ../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:591
+#: ../roundup/admin.py:560
+#, python-format
+msgid "no such %(classname)s property \"%(propname)s\""
+msgstr ""
+"Die Eigenschaft \"%(propname)s\" ist für die Klasse \"%"
+"(classname)s\" nicht definiert"
+
+#: ../roundup/admin.py:600
+#: ../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 mit Kommata getrennten Bezeichnern aufführen\n"
+"        (\"Bezeichner[,Bezeichner]*\").\n"
+"\n"
+"        Der Wert der Eigenschaft wird für alle angegebenen Einträge 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 mit Kommata getrennte ID-Nummern angegeben (\"1,2,3\").\n"
+"        "
+
+#: ../roundup/admin.py:655
+#: ../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:708 ../roundup/admin.py:862 ../roundup/admin.py:874
+#: ../roundup/admin.py:928 ../roundup/admin.py:708:862 :874:928
+# ../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:715
+#: ../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 Attribute der Klasse aus.\n"
+"\n"
+"        Zeigt sämtliche Eigenschaften der Klasse auf.\n"
+"        "
+
+#: ../roundup/admin.py:730
+#: ../roundup/admin.py:696
+#, python-format
+msgid "%(key)s: %(value)s (key property)"
+msgstr "%(key)s: %(value)s (Schlüsseleigenschaft)"
+
+#: ../roundup/admin.py:732 ../roundup/admin.py:759 ../roundup/admin.py:732:759
+#: ../roundup/admin.py:698
+#, python-format
+msgid "%(key)s: %(value)s"
+msgstr "%(key)s: %(value)s"
+
+#: ../roundup/admin.py:735
+#: ../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 zeigt die Eigenschaften und Ihre Werte des\n"
+"        gewählten Eintrags an.\n"
+"        "
+
+#: ../roundup/admin.py:725
+#, python-format
+msgid "%(key)s: %(value)r"
+msgstr "%(key)s: %(value)r"
+
+#: ../roundup/admin.py:762
+#: ../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 einen Wert für \"%(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 aus der folgenden\n"
+"        Liste generiert, mit absteigender Priorität:\n"
+"        Schlüsselfeld, ein Feld namens \"name\" oder \"title\". Falls\n"
+"        auch diese Felder nicht existieren, wird das \n"
+"        erste Feld alphabetisch sortiert angezeigt.\n"
+"\n"
+"        Mit den Optionen -c, -S or -s wird eine Liste von IDs ausgegeben,\n"
+"        falls keine Eigenschaft angegeben wird. Sonst werden die Werte\n"
+"        dieser Eigenschaften sämtlicher Instanzen dieser Klasse "
+"aufgelistet.\n"
+"        "
+
+#: ../roundup/admin.py:840
+#: ../roundup/admin.py:805
+msgid "Too many arguments supplied"
+msgstr "Sie haben zuviele Argumente übergeben"
+
+#: ../roundup/admin.py:876
+#: ../roundup/admin.py:841
+#, python-format
+msgid "%(nodeid)4s: %(value)s"
+msgstr "%(nodeid)4s: %(value)s"
+
+#: ../roundup/admin.py:880
+#: ../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 tabellarischer Form.\n"
+"\n"
+"        Dieser Befehl 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"
+"        als \"Eigenschaft:Breite\" angegeben werden.\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 Doppelpunkt 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:924
+#: ../roundup/admin.py:889
+#, python-format
+msgid "\"%(spec)s\" not name:width"
+msgstr "\"%(spec)s\" entspricht nicht dem Format Eigenschaft:Breite"
+
+#: ../roundup/admin.py:974
+#: ../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 Eintrags an.\n"
+"\n"
+"        Listet das Bearbeitungs-Journal des Eintrags mit dem angegebenen\n"
+"        Bezeichner auf.\n"
+"        "
+
+#: ../roundup/admin.py:995
+#: ../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:1010
+#: ../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:1023
+#: ../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 Eintrags bewirkt, dass dieser bei einer Suche\n"
+"        nicht mehr angezeigt wird. Der Schlüssel des verborgenen Eintrags\n"
+"        kann zudem wiederverwendet werden.\n"
+"        "
+
+#: ../roundup/admin.py:1047
+#: ../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 wiederhergestellt.\n"
+"\n"
+"        Ein verborgener Eintrag wird wiederhergestellt und ist danach\n"
+"        für die Benutzer wieder sichtbar.\n"
+"        "
+
+#. grab the directory to export to
+#: ../roundup/admin.py:1070
+#: ../roundup/admin.py:1031
+msgid ""
+"Usage: export [[-]class[,class]] export_dir\n"
+"        Export the database to colon-separated-value files.\n"
+"        To exclude the files (e.g. for the msg or file class),\n"
+"        use the exporttables command.\n"
+"\n"
+"        Optionally limit the export to just the named classes\n"
+"        or exclude the named classes, if the 1st argument starts with '-'.\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"
+"        Um die im Dateisystem abgelegten Daten fortzulassen (z. B.\n"
+"        die Klassen msg und file), verwenden Sie \"exporttables\".\n"
+"\n"
+"        Wenn Sie Klassennamen übergeben, wird der Export auf diese beschränkt\n"
+"        bzw. (wenn der ersten Klasse ein '-' vorgestellt wird) diese fortgelassen.\n"
+"\n"
+"        Die Daten werden als kommagetrennte Dateien in das angegebene\n"
+"        Exportverzeichnis geschrieben.\n"
+"        "
+
+#: ../roundup/admin.py:1145
+#: ../roundup/admin.py:1145
+msgid ""
+"Usage: exporttables [[-]class[,class]] export_dir\n"
+"        Export the database to colon-separated-value files, excluding the\n"
+"        files below $TRACKER_HOME/db/files/ (which can be archived "
+"separately).\n"
+"        To include the files, use the export command.\n"
+"\n"
+"        Optionally limit the export to just the named classes\n"
+"        or exclude the named classes, if the 1st argument starts with '-'.\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: exporttables [Klasse[,Klasse]] Exportverzeichnis\n"
+"        Exportiert die Datenbank in ein Verzeichnis mit CSV-Dateien,\n"
+"        unter Fortlassung der im Dateisystem unter $TRACKER_HOME/db/files\n"
+"        abgelegten Daten; um diese mitzuexportieren, verwenden Sie \"export\".\n"
+"\n"
+"        Wenn Sie Klassennamen übergeben, wird der Export auf diese beschränkt\n"
+"        bzw. (wenn der ersten Klasse ein '-' vorgestellt wird) diese fortgelassen.\n"
+"\n"
+"        Die Daten werden als kommagetrennte Dateien in das angegebene\n"
+"        Exportverzeichnis geschrieben.\n"
+"        "
+
+#: ../roundup/admin.py:1160
+#: ../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 IDs, welche in den Dateien\n"
+"        definiert sind. Existierende Einträge mit denselben IDs 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:1235
+#: ../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:1263
+#: ../roundup/admin.py:1180
+msgid "Invalid format"
+msgstr "Ungültiges Format"
+
+#: ../roundup/admin.py:1274
+#: ../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. Dies geschieht \n"
+"        normalerweise automatisch.\n"
+"        "
+
+#: ../roundup/admin.py:1288
+#: ../roundup/admin.py:1204
+#, python-format
+msgid "no such item \"%(designator)s\""
+msgstr "Der Eintrag \"%(designator)s\" existiert nicht"
+
+#: ../roundup/admin.py:1298
+#: ../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 E-Mail-Benutzer erhalten die Rollen \"%(role)s\""
+
+#: ../roundup/admin.py:1235
+#, python-format
+msgid "New Email users get the Role \"%(role)s\""
+msgstr "Neue E-Mail-Benutzer erhalten die Rolle \"%(role)s\""
+
+#: ../roundup/admin.py:1238
+#, python-format
+msgid "Role \"%(name)s\":"
+msgstr "Rolle \"%(name)s\":"
+
+#: ../roundup/admin.py:1280
+#, python-format
+msgid " %(description)s (%(name)s for \"%(klass)s\": %(properties)s only)"
+msgstr " %(description)s (%(name)s für \"%(klass)s\": ausschließlich %(properties)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:1426
+#: ../roundup/admin.py:1337
+#, on my Windows(tm) system, they *are* available
+msgid "Note: command history and editing not available"
+msgstr "Bemerkung: Befehlsverlauf/-bearbeitung möglicherweise nicht verfügbar"
+
+#: ../roundup/admin.py:1430
+#: ../roundup/admin.py:1341
+msgid "roundup> "
+msgstr "roundup> "
+
+#: ../roundup/admin.py:1432
+#: ../roundup/admin.py:1343
+msgid "exit..."
+msgstr "beenden..."
+
+#: ../roundup/admin.py:1442
+#: ../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:219
+#: ../roundup/backends/sessions_dbm.py:50
+msgid "Couldn't identify database type"
+msgstr ""
+"Konnte den Datenbanktyp nicht ermitteln"
+
+#: ../roundup/backends/back_anydbm.py:245
+#, python-format
+msgid "Couldn't open database - the required module '%s' is not available"
+msgstr ""
+"Konnte die Datenbank nicht öffnen - das erforderliche Modul '%s' ist nicht verfügbar"
+
+#: ../roundup/backends/back_anydbm.py:795
+#: ../roundup/backends/back_anydbm.py:1070
+#: ../roundup/backends/back_anydbm.py:1267
+#: ../roundup/backends/back_anydbm.py:1285
+#: ../roundup/backends/back_anydbm.py:1331
+#: ../roundup/backends/back_anydbm.py:1901
+#: ../roundup/backends/back_anydbm.py:795:1070
+#: ../roundup/backends/back_metakit.py:567
+#: ../roundup/backends/back_metakit.py:834
+#: ../roundup/backends/back_metakit.py:866
+#: ../roundup/backends/back_metakit.py:1601
+#: ../roundup/backends/back_metakit.py:567:834
+#: ../roundup/backends/rdbms_common.py:1320
+#: ../roundup/backends/rdbms_common.py:1549
+#: ../roundup/backends/rdbms_common.py:1755
+#: ../roundup/backends/rdbms_common.py:1775
+#: ../roundup/backends/rdbms_common.py:1828
+#: ../roundup/backends/rdbms_common.py:2436
+#: ../roundup/backends/rdbms_common.py:1320:1549 :1267:1285 :1331:1901
+#: :1755:1775 :1828:2436 :866:1601
+msgid "Database open read-only"
+msgstr ""
+"Datenbank nur zum Lesen geöffnet"
+
+#: ../roundup/backends/back_anydbm.py:2003
+#: ../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:1449
+#: ../roundup/backends/rdbms_common.py:1425
+msgid "create"
+msgstr "erstellt"
+
+#: ../roundup/backends/rdbms_common.py:1615
+#: ../roundup/backends/rdbms_common.py:1588
+msgid "unlink"
+msgstr "Link gelöscht"
+
+#: ../roundup/backends/rdbms_common.py:1619
+#: ../roundup/backends/rdbms_common.py:1592
+msgid "link"
+msgstr "verlinkt"
+
+#: ../roundup/backends/rdbms_common.py:1741
+#: ../roundup/backends/rdbms_common.py:1702
+msgid "set"
+msgstr "geändert"
+
+#: ../roundup/backends/rdbms_common.py:1765
+#: ../roundup/backends/rdbms_common.py:1726
+msgid "retired"
+msgstr "verborgen"
+
+#: ../roundup/backends/rdbms_common.py:1795
+#: ../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 sind nicht berechtigt, die Aktion(en) %(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:169 ../roundup/cgi/actions.py:197
+#: ../roundup/cgi/actions.py:169:197
+# ../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 "Sie haben keine Berechtigung, Abfragen zu bearbeiten."
+
+#: ../roundup/cgi/actions.py:175 ../roundup/cgi/actions.py:204
+#: ../roundup/cgi/actions.py:175:204
+# ../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 "Sie haben keine Berechtigung, Abfragen zu speichern."
+
+#: ../roundup/cgi/actions.py:310
+#: ../roundup/cgi/actions.py:279
+#, python-format
+msgid "Not enough values on line %(line)s"
+msgstr "Nicht genügend Werte in Zeile %(line)s"
+
+#: ../roundup/cgi/actions.py:357
+#: ../roundup/cgi/actions.py:326
+msgid "Items edited OK"
+msgstr "Die Einträge wurden aktualisiert"
+
+#: ../roundup/cgi/actions.py:416
+#: ../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:419
+#: ../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:431
+#: ../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:463
+#: ../roundup/cgi/actions.py:433
+#, python-format
+msgid "You do not have permission to edit %(class)s"
+msgstr ""
+"Sie sind nicht berechtigt, Einträge der Klasse \"%(class)s\" zu "
+"bearbeiten"
+
+#: ../roundup/cgi/actions.py:475
+#: ../roundup/cgi/actions.py:445
+#, python-format
+msgid "You do not have permission to create %(class)s"
+msgstr ""
+"Sie sind nicht berechtigt, Einträge der Klasse \"%(class)s\" zu "
+"erstellen"
+
+#: ../roundup/cgi/actions.py:499
+#: ../roundup/cgi/actions.py:468
+msgid "You do not have permission to edit user roles"
+msgstr "Sie sind nicht berechtigt, Benutzer-Rollen zu ändern"
+
+#: ../roundup/cgi/actions.py:549
+#: ../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 ""
+"Fehler: Jemand anders hat dieses %s bearbeitet (%s). Sehen Sie "
+"<a target=\"_new\" href=\"%s%s\">dessen Änderungen</a> in einem neuen Fenster."
+
+#: ../roundup/cgi/actions.py:577
+#: ../roundup/cgi/actions.py:530
+#, python-format
+msgid "Edit Error: %s"
+msgstr "Fehler bei der Bearbeitung: %s"
+
+#: ../roundup/cgi/actions.py:608 ../roundup/cgi/actions.py:619
+#: ../roundup/cgi/actions.py:790 ../roundup/cgi/actions.py:809
+#: ../roundup/cgi/actions.py:608:619 :790:809
+# ../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:645
+#: ../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 E-Mail-Konto)"
+
+#: ../roundup/cgi/actions.py:640
+#, python-format
+msgid "Password reset and email sent to %s"
+msgstr "Ihr Passwort wurde zurückgesetzt und per E-Mail an %s versandt"
+
+#: ../roundup/cgi/actions.py:649
+msgid "Unknown username"
+msgstr "Benutzername unbekannt"
+
+#: ../roundup/cgi/actions.py:657
+msgid "Unknown email address"
+msgstr "E-Mail-Adresse unbekannt"
+
+#: ../roundup/cgi/actions.py:662
+msgid "You need to specify a username or address"
+msgstr "Sie müssen einen Benutzernamen oder eine E-Mail-Adresse angeben"
+
+#: ../roundup/cgi/actions.py:687
+#, python-format
+msgid "Email sent to %s"
+msgstr "Eine E-Mail 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ültiger Benutzername"
+
+#: ../roundup/cgi/actions.py:883
+msgid "You do not have permission to login"
+msgstr "Sie sind nicht berechtigt, 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>Fehler in einer Vorlage:</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>Suche nach \"%(name)s\", aktuelles Verzeichnis:<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 in der Vorlage \"%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 dem Fehler führten. Der letzte (innerste) Aufruf erscheint "
+"dabei 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:51
+#: ../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>Ein Fehler ist aufgetreten</title></head>\n"
+"<body><h1>Ein Fehler ist aufgetreten</h1>\n"
+"<p>Bei der Bearbeitung Ihrer Daten ist ein Fehler aufgetreten. "
+"Die Admistratoren wurden benachrichtigt.</p>\n"
+"</body></html>"
+
+#: ../roundup/cgi/client.py:377
+#: ../roundup/cgi/client.py:291
+msgid "Form Error: "
+msgstr "Formular-Fehler: "
+
+#: ../roundup/cgi/client.py:432
+#: ../roundup/cgi/client.py:344
+#, python-format
+msgid "Unrecognized charset: %r"
+msgstr "Zeichensatz nicht erkannt: %r"
+
+#: ../roundup/cgi/client.py:560
+#: ../roundup/cgi/client.py:446
+msgid "Anonymous users are not allowed to use the web interface"
+msgstr ""
+"Gast-Benutzer sind nicht berechtigt, das Web-Interface zu benutzen."
+
+#: ../roundup/cgi/client.py:715
+#: ../roundup/cgi/client.py:597
+msgid "You are not allowed to view this file."
+msgstr "Sie sind nicht berechtigt, diese Seite anzuzeigen."
+
+#: ../roundup/cgi/client.py:808
+#: ../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:812
+#: ../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 \"%(entry)s\" not a designator"
+msgstr ""
+"Der Wert \"%(entry)s\" ist kein gültiger Bezeichner für die Verknüpfung \"%"
+"(key)s\""
+
+#: ../roundup/cgi/form_parser.py:301
+#: ../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:335
+#: ../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 Passwortfelder 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:574
+#: ../roundup/cgi/form_parser.py:529
+msgid "File is empty"
+msgstr "Die ausgewählte Datei ist leer"
+
+#: ../roundup/cgi/templating.py:77
+#: ../roundup/cgi/templating.py:68
+#, python-format
+msgid "You are not allowed to %(action)s items of class %(class)s"
+msgstr ""
+"Sie sind nicht berechtigt, die Aktion  \"%(action)s\" auf Einträge der "
+"Klasse \"%(class)s\" anzuwenden"
+
+#: ../roundup/cgi/templating.py:657
+#: ../roundup/cgi/templating.py:77
+#: ../roundup/cgi/templating.py:612
+msgid "(list)"
+msgstr "(Liste)"
+
+#: ../roundup/cgi/templating.py:726
+#: ../roundup/cgi/templating.py:646
+msgid "Submit New Entry"
+msgstr "Eintrag speichern"
+
+#: ../roundup/cgi/templating.py:740 ../roundup/cgi/templating.py:873
+#: ../roundup/cgi/templating.py:1294 ../roundup/cgi/templating.py:1323
+#: ../roundup/cgi/templating.py:1343 ../roundup/cgi/templating.py:1356
+#: ../roundup/cgi/templating.py:1407 ../roundup/cgi/templating.py:1430
+#: ../roundup/cgi/templating.py:1466 ../roundup/cgi/templating.py:1503
+#: ../roundup/cgi/templating.py:1556 ../roundup/cgi/templating.py:1573
+#: ../roundup/cgi/templating.py:1657 ../roundup/cgi/templating.py:1677
+#: ../roundup/cgi/templating.py:1695 ../roundup/cgi/templating.py:1727
+#: ../roundup/cgi/templating.py:1737 ../roundup/cgi/templating.py:1789
+#: ../roundup/cgi/templating.py:1978 ../roundup/cgi/templating.py:740:873
+#: :1294:1323 :1343:1356 :1407:1430 :1466:1503 :1556:1573 :1657:1677 :1695:1727
+#: :1737:1789 :1978
+msgid "[hidden]"
+msgstr "[verborgen]"
+
+#: ../roundup/cgi/templating.py:741
+#: ../roundup/cgi/templating.py:656
+msgid "New node - no history"
+msgstr "Neuer Eintrag - Noch kein Verlauf"
+
+#: ../roundup/cgi/templating.py:855
+#: ../roundup/cgi/templating.py:756
+msgid "Submit Changes"
+msgstr "Speichern"
+
+#: ../roundup/cgi/templating.py:937
+#: ../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:938
+#: ../roundup/cgi/templating.py:838
+#, python-format
+msgid "<em>%s: %s</em>\n"
+msgstr "<em>%s: %s</em>\n"
+
+#: ../roundup/cgi/templating.py:951
+#: ../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:984 ../roundup/cgi/templating.py:1008
+#: ../roundup/cgi/templating.py:984:1008
+# ../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:1061
+#: ../roundup/cgi/templating.py:955
+#, python-format
+msgid "%s: (no value)"
+msgstr "%s: (kein Wert)"
+
+#: ../roundup/cgi/templating.py:1073
+#: ../roundup/cgi/templating.py:967
+msgid ""
+"<strong><em>This event is not handled by the history display!</em></strong>"
+msgstr ""
+"<strong><em>Dieses Ereignis kann nicht im Verlauf angezeigt werden!</em></strong>"
+
+#: ../roundup/cgi/templating.py:1085
+#: ../roundup/cgi/templating.py:979
+msgid "<tr><td colspan=4><strong>Note:</strong></td></tr>"
+msgstr "<tr><td colspan=\"4\"><strong>Bitte beachten:</strong></td></tr>"
+
+#: ../roundup/cgi/templating.py:1094
+#: ../roundup/cgi/templating.py:988
+msgid "History"
+msgstr "Verlauf"
+
+#: ../roundup/cgi/templating.py:1096
+#: ../roundup/cgi/templating.py:990
+msgid "<th>Date</th>"
+msgstr "<th>Datum</th>"
+
+#: ../roundup/cgi/templating.py:1097
+#: ../roundup/cgi/templating.py:991
+msgid "<th>User</th>"
+msgstr "<th>Benutzer</th>"
+
+#: ../roundup/cgi/templating.py:1098
+#: ../roundup/cgi/templating.py:992
+msgid "<th>Action</th>"
+msgstr "<th>Aktion</th>"
+
+#: ../roundup/cgi/templating.py:1099
+#: ../roundup/cgi/templating.py:993
+msgid "<th>Args</th>"
+msgstr "<th>Argumente</th>"
+
+#: ../roundup/cgi/templating.py:1141
+#: ../roundup/cgi/templating.py:1097
+#, python-format
+msgid "Copy of %(class)s %(id)s"
+msgstr ""
+"Kopie von %(class)s %(id)s"
+
+#: ../roundup/cgi/templating.py:1434
+#: ../roundup/cgi/templating.py:1234
+msgid "*encrypted*"
+msgstr "*verschlüsselt*"
+
+#: ../roundup/cgi/templating.py:1507 ../roundup/cgi/templating.py:1528
+#: ../roundup/cgi/templating.py:1534 ../roundup/cgi/templating.py:1050:1507
+#: :1528:1534
+msgid "No"
+msgstr "Nein"
+
+#: ../roundup/cgi/templating.py:1507 ../roundup/cgi/templating.py:1526
+#: ../roundup/cgi/templating.py:1531 ../roundup/cgi/templating.py:1050:1507
+#: :1526:1531
+msgid "Yes"
+msgstr "Ja"
+
+#: ../roundup/cgi/templating.py:1620
+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:1780
+#, python-format
+msgid "Attempt to look up %(attr)s on a missing value"
+msgstr ""
+"Versuch, das Attribut %(attr)s eines nicht vorhandenen Werts abzufragen"
+
+#: ../roundup/cgi/templating.py:1853
+#, python-format
+msgid "<option %svalue=\"-1\">- no selection -</option>"
+msgstr "<option %svalue=\"-1\">- nichts ausgewählt -</option>"
+
+#: ../roundup/date.py:180
+#, python-format
+msgid "Not a date spec: %s"
+msgstr "Kein gültiges Datum: %s"
+
+#: ../roundup/date.py:300
+msgid ""
+"Not a date spec: \"yyyy-mm-dd\", \"mm-dd\", \"HH:MM\", \"HH:MM:SS\" or \"yyyy-"
+"mm-dd.HH:MM:SS.SSS\""
+msgstr ""
+"Kein gültiges Datum: \"yyyy-mm-dd\", \"mm-dd\", \"HH:MM\", \"HH:MM:SS\" oder \"yyyy-"
+"mm-dd.HH:MM:SS.SSS\""
+
+#: ../roundup/date.py:359
+#, 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 ist keine Datums- oder Zeitangabe (\"yyyy-mm-dd\", \"mm-dd\", \"HH:MM\", \"HH:MM:SS\" "
+"oder \"yyyy-mm-dd.HH:MM:SS.SSS\")"
+
+#: ../roundup/date.py:666
+#: ../roundup/date.py:522
+msgid ""
+"Not an interval spec: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS] [date spec]"
+msgstr ""
+"Keine gültige Intervall-Angabe:"
+" [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS] [Datum]"
+
+#: ../roundup/date.py:685
+msgid "Not an interval spec: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS]"
+msgstr ""
+"Keine gültige Intervall-Angabe:"
+" [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS]"
+
+#: ../roundup/date.py:822
+#: ../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:826
+#: ../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:830
+#: ../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:834
+#: ../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:838
+#: ../roundup/date.py:694
+msgid "tomorrow"
+msgstr "morgen"
+
+#: ../roundup/date.py:840
+#: ../roundup/date.py:696
+msgid "yesterday"
+msgstr "gestern"
+
+#: ../roundup/date.py:843
+#: ../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:847
+#: ../roundup/date.py:703
+msgid "an hour"
+msgstr "eine Stunde"
+
+#: ../roundup/date.py:849
+#: ../roundup/date.py:705
+msgid "1 1/2 hours"
+msgstr "1 1/2 Stunden"
+
+#: ../roundup/date.py:851
+#: ../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 Stunde"
+msgstr[1] "1 %(number)s/4 Stunden"
+
+#: ../roundup/date.py:855
+#: ../roundup/date.py:711
+msgid "in a moment"
+msgstr "in Kürze"
+
+#: ../roundup/date.py:857
+#: ../roundup/date.py:713
+msgid "just now"
+msgstr "soeben"
+
+#: ../roundup/date.py:860
+#: ../roundup/date.py:716
+msgid "1 minute"
+msgstr "1 Minute"
+
+#: ../roundup/date.py:863
+#: ../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:866
+#: ../roundup/date.py:722
+msgid "1/2 an hour"
+msgstr "1/2 Stunde"
+
+#: ../roundup/date.py:868
+#: ../roundup/date.py:724
+#, python-format
+msgid "%(number)s/4 hour"
+msgid_plural "%(number)s/4 hours"
+msgstr[0] "%(number)s/4 Stunde"
+msgstr[1] "%(number)s/4 Stunden"
+
+#: ../roundup/date.py:872
+#: ../roundup/date.py:728
+#, python-format
+msgid "%s ago"
+msgstr "vor %s"
+
+#: ../roundup/date.py:874
+#: ../roundup/date.py:730
+#, python-format
+msgid "in %s"
+msgstr "in %s"
+
+#: ../roundup/hyperdb.py:87
+#, python-format
+msgid "property %s: %s"
+msgstr ""
+ "Eigenschaft %s: %s"
+
+#: ../roundup/hyperdb.py:107
+#, python-format
+msgid "property %s: %r is an invalid date (%s)"
+msgstr ""
+"Eigenschaft %s: %r ist kein gültiges Datum (%s)"
+
+#: ../roundup/hyperdb.py:124
+#, python-format
+msgid "property %s: %r is an invalid date interval (%s)"
+msgstr ""
+"Eigenschaft %s: %r ist kein gültiges Datumsintervall (%s)"
+
+#: ../roundup/hyperdb.py:219
+#, python-format
+msgid "property %s: %r is not currently an element"
+msgstr ""
+"Eigenschaft %s: %r ist derzeit kein Element"
+
+#: ../roundup/hyperdb.py:263
+#, python-format
+msgid "property %s: %r is not a number"
+msgstr ""
+"Eigenschaft %s: %r ist keine Zahl"
+
+#: ../roundup/hyperdb.py:276
+#, python-format
+msgid "\"%s\" not a node designator"
+msgstr "\"%s\" ist kein gültiger Bezeichner"
+
+#: ../roundup/hyperdb.py:949 ../roundup/hyperdb.py:957
+#: ../roundup/hyperdb.py:949:957
+#, python-format
+msgid "Not a property name: %s"
+msgstr "Keine Eigenschaft: %s"
+
+#: ../roundup/hyperdb.py:1240
+#, python-format
+msgid "property %s: %r is not a %s."
+msgstr ""
+"Eigenschaft %s: %r ist kein %s."
+
+#: ../roundup/hyperdb.py:1243
+#, python-format
+msgid "you may only enter ID values for property %s"
+msgstr ""
+
+#: ../roundup/hyperdb.py:1273
+#, python-format
+msgid "%r is not a property of %s"
+msgstr ""
+
+#: ../roundup/init.py:134
+#, 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/mailgw.py:199 ../roundup/mailgw.py:211
+#: ../roundup/mailgw.py:199:211
+#, python-format
+msgid "Message signed with unknown key: %s"
+msgstr ""
+"Nachricht signiert mit unbekanntem Schlüssel: %s"
+
+#: ../roundup/mailgw.py:202
+#, python-format
+msgid "Message signed with an expired key: %s"
+msgstr ""
+"Nachricht signiert mit abgelaufenem Schlüssel: %s"
+
+#: ../roundup/mailgw.py:205
+#, python-format
+msgid "Message signed with a revoked key: %s"
+msgstr ""
+"Nachricht signiert mit zurückgezogenem Schlüssel: %s"
+
+#: ../roundup/mailgw.py:208
+msgid "Invalid PGP signature detected."
+msgstr ""
+"Ungültige PGP-Signatur festgestellt."
+
+#: ../roundup/mailgw.py:404
+msgid "Unknown multipart/encrypted version."
+msgstr ""
+
+#: ../roundup/mailgw.py:413
+msgid "Unable to decrypt your message."
+msgstr ""
+"Kann Ihre Nachricht nicht entschlüsseln"
+
+#: ../roundup/mailgw.py:442
+msgid "No PGP signature found in message."
+msgstr ""
+"Keine PGP-Signatur in Nachricht gefunden"
+
+#: ../roundup/mailgw.py:749
+msgid ""
+"\n"
+"Emails to Roundup trackers must include a Subject: line!\n"
+msgstr ""
+
+#: ../roundup/mailgw.py:674
+#: ../roundup/mailgw.py:873
+#, python-format
+msgid ""
+"\n"
+"The message you sent to roundup did not contain a properly formed subject\n"
+"line. The subject must contain a class name or designator to indicate the\n"
+"'topic' of the message. For example:\n"
+"    Subject: [issue] This is a new issue\n"
+"      - this will create a new issue in the tracker with the title 'This is\n"
+"        a new issue'.\n"
+"    Subject: [issue1234] This is a followup to issue 1234\n"
+"      - this will append the message's contents to the existing issue 1234\n"
+"        in the tracker.\n"
+"\n"
+"Subject was: '%(subject)s'\n"
+msgstr ""
+
+#: ../roundup/mailgw.py:911
+#, python-format
+msgid ""
+"\n"
+"The class name you identified in the subject line (\"%(classname)s\") does\n"
+"not exist in the database.\n"
+"\n"
+"Valid class names are: %(validname)s\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"\n"
+"Der von Ihnen in der Betreffzeile angegebene Klassenname (\"%(classname)s\")"
+"existiert in der Datenbank nicht."
+"\n"
+"Gültige Klassen sind: %(validname)s\n"
+"Die Betreffzeile war: \"%(subject)s\"\n"
+
+#: ../roundup/mailgw.py:919
+#, python-format
+msgid ""
+"\n"
+"You did not identify a class name in the subject line and there is no\n"
+"default set for this tracker. The subject must contain a class name or\n"
+"designator to indicate the 'topic' of the message. For example:\n"
+"    Subject: [issue] This is a new issue\n"
+"      - this will create a new issue in the tracker with the title 'This is\n"
+"        a new issue'.\n"
+"    Subject: [issue1234] This is a followup to issue 1234\n"
+"      - this will append the message's contents to the existing issue 1234\n"
+"        in the tracker.\n"
+"\n"
+"Subject was: '%(subject)s'\n"
+msgstr ""
+"\n"
+"Sie haben in der Betreffzeile keinen Klassennamen angegeben, und es ist für\n"
+"diesen Tracker kein Standardwert gesetzt. Die Betreffzeile muß eine Klasse\n"
+"oder einen Bezeichner des Gegenstands Ihrer Nachricht enthalten;\n"
+"zum Beispiel:\n"
+"    Subject: [issue] Dies ist ein neues Issue\n"
+"      - dies erzeugt ein neues Issue im Tracker mit dem Titel \'Dies\n"
+"        ist ein neues Issue\'.\n"
+"    Subject: [issue1234] Dies bezieht sich auf Issue 1234\n"
+"      - fügt den Inhalt der Nachricht dem existierenden Issue 1234 hinzu\n"
+"\n"
+"Die Betreffzeile (Subject) war:\n"
+"   '%(subject)s'\n"
+
+#: ../roundup/mailgw.py:960
+#, python-format
+msgid ""
+"\n"
+"I cannot match your message to a node in the database - you need to either\n"
+"supply a full designator (with number, eg \"[issue123]\") or keep the\n"
+"previous subject title intact so I can match that.\n"
+"\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"\n"
+"Ich kann Ihre Nachricht keinem Eintrag in der Datenbank zuordnen - Sie müssen\n"
+"entweder einen vollen Bezeichner angeben (mit Nummer, z. B. \"[issue123]\")\n"
+"oder die Betreffzeile intakt lassen, so daß ich diese zuordnen kann.\n"
+"\n"
+"Die Betreffzeile (Subject) war:\n"
+"   '%(subject)s'\n"
+
+#: ../roundup/mailgw.py:733
+#, (old version)
+#, python-format
+msgid ""
+"\n"
+"I cannot match your message to a node in the database - you need to either\n"
+"supply a full designator (with number, eg \"[issue123]\" or keep the\n"
+"previous subject title intact so I can match that.\n"
+"\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"\n"
+"Ich kann Ihre Nachricht keinem Eintrag in der Datenbank zuordnen - Sie müssen\n"
+"entweder einen vollen Bezeichner angeben (mit Nummer, z. B. \"[issue123]\")\n"
+"oder die Betreffzeile intakt lassen, so daß ich diese zuordnen kann.\n"
+"\n"
+"Die Betreffzeile (Subject) war:\n"
+"   '%(subject)s'\n"
+
+
+#: ../roundup/mailgw.py:993
+#: ../roundup/mailgw.py:766
+#, python-format
+msgid ""
+"\n"
+"The node specified by the designator in the subject of your message\n"
+"(\"%(nodeid)s\") does not exist.\n"
+"\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"Der in der Betreffzeile Ihre Nachricht bezeichnete Eintrag\n"
+"(\"%(nodeid)s\") existiert nicht.\n"
+"\n"
+"Die Betreffzeile (Subject) war:\n"
+"   '%(subject)s'\n"
+
+#: ../roundup/mailgw.py:1021
+#: ../roundup/mailgw.py:794
+#, python-format
+msgid ""
+"\n"
+"The mail gateway is not properly set up. Please contact\n"
+"%(mailadmin)s and have them fix the incorrect class specified as:\n"
+"  %(current_class)s\n"
+msgstr ""
+
+#: ../roundup/mailgw.py:1044
+#: ../roundup/mailgw.py:817
+#, python-format
+msgid ""
+"\n"
+"The mail gateway is not properly set up. Please contact\n"
+"%(mailadmin)s and have them fix the incorrect properties:\n"
+"  %(errors)s\n"
+msgstr ""
+
+#: ../roundup/mailgw.py:1084
+#, python-format
+msgid ""
+"\n"
+"You are not a registered user.%(registration_info)s\n"
+"\n"
+"Unknown address: %(from_address)s\n"
+msgstr ""
+"\n"
+"Sie sind kein registrierter Anwender.%(registration_info)s\n"
+"\n"
+"Unbekannte Adresse: %(from_address)s\n"
+
+#: ../roundup/mailgw.py:847
+#, (old version)
+#, python-format
+msgid ""
+"\n"
+"You are not a registered user.\n"
+"\n"
+"Unknown address: %(from_address)s\n"
+msgstr ""
+"\n"
+"Sie sind kein registrierter Anwender.\n"
+"\n"
+"Unbekannte Adresse: %(from_address)s\n"
+
+#: ../roundup/mailgw.py:1092
+#: ../roundup/mailgw.py:855
+msgid "You are not permitted to access this tracker."
+msgstr ""
+"Sie haben keinen Zugriff auf diesen Tracker."
+
+#: ../roundup/mailgw.py:1099
+#: ../roundup/mailgw.py:862
+#, python-format
+msgid "You are not permitted to edit %(classname)s."
+msgstr ""
+"Sie sind nicht berechtigt, %(classname)s zu bearbeiten"
+
+#: ../roundup/mailgw.py:1103
+#: ../roundup/mailgw.py:866
+#, python-format
+msgid "You are not permitted to create %(classname)s."
+msgstr ""
+"Sie sind nicht berechtigt, ein \"%(classname)s\" zu erzeugen"
+
+#: ../roundup/mailgw.py:1150
+#: ../roundup/mailgw.py:913
+#, python-format
+msgid ""
+"\n"
+"There were problems handling your subject line argument list:\n"
+"- %(errors)s\n"
+"\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"\n"
+"Es sind Probleme aufgetreten bei der Verarbeitung Ihrer Betreffzeile:\n"
+"- %(errors)s\n"
+"\n"
+"Die Betreffzeile war: \"%(subject)s\"\n"
+
+#: ../roundup/mailgw.py:1203
+msgid ""
+"\n"
+"This tracker has been configured to require all email be PGP signed or\n"
+"encrypted."
+msgstr ""
+"\n"
+"Dieser Tracker wurde konfiguriert, Email-Nachrichten nur PGP-signiert oder\n"
+"verschlüsselt entgegenzunehmen."
+
+#: ../roundup/mailgw.py:1209
+#: ../roundup/mailgw.py:942
+msgid ""
+"\n"
+"Roundup requires the submission to be plain text. The message parser could\n"
+"not find a text/plain part to use.\n"
+msgstr ""
+"\n"
+"Dieser Tracker akzeptiert nur einfache Textnachrichten. Der Mail-Parser konnte\n"
+"keinen entsprechenden Teil (\"text/plain\") finden.\n"
+
+#: ../roundup/mailgw.py:1226
+#: ../roundup/mailgw.py:964
+msgid "You are not permitted to create files."
+msgstr ""
+"Sie sind nicht berechtigt, Dateien zu erzeugen"
+
+#: ../roundup/mailgw.py:1240
+#: ../roundup/mailgw.py:978
+#, python-format
+msgid "You are not permitted to add files to %(classname)s."
+msgstr ""
+"Sie sind nicht berechtigt, Dateien zu %(classname)s hinzuzufügen"
+
+#: ../roundup/mailgw.py:1258
+#: ../roundup/mailgw.py:996
+msgid "You are not permitted to create messages."
+msgstr ""
+"Sie sind nicht berechtigt, Nachrichten zu erzeugen"
+
+#: ../roundup/mailgw.py:1266
+#: ../roundup/mailgw.py:1004
+#, python-format
+msgid ""
+"\n"
+"Mail message was rejected by a detector.\n"
+"%(error)s\n"
+msgstr ""
+"Die Mail-Nachricht wurde von einem Detektor zurückgewiesen.\n"
+"%(error)s\n"
+
+#: ../roundup/mailgw.py:1274
+#: ../roundup/mailgw.py:1012
+#, python-format
+msgid "You are not permitted to add messages to %(classname)s."
+msgstr ""
+"Sie sind nicht berechtigt, Kommentare zu %(classname)s hinzuzufügen"
+
+#: ../roundup/mailgw.py:1301
+#: ../roundup/mailgw.py:1039
+#, python-format
+msgid "You are not permitted to edit property %(prop)s of class %(classname)s."
+msgstr ""
+"Sie sind nicht berechtigt, die Eigenschaft %(prop)s der Klasse %(classname)s\n"
+"zu bearbeiten."
+
+#: ../roundup/mailgw.py:1309
+#: ../roundup/mailgw.py:1047
+#, python-format
+msgid ""
+"\n"
+"There was a problem with the message you sent:\n"
+"   %(message)s\n"
+msgstr ""
+"\n"
+"Es gab ein Problem mit Ihrer Nachricht:\n"
+"   %(message)s\n"
+
+#: ../roundup/mailgw.py:1331
+#: ../roundup/mailgw.py:1069
+msgid "not of form [arg=value,value,...;arg=value,value,...]"
+msgstr ""
+"entspricht nicht der Form [arg=wert,wert,...;arg=wert,wert,...]"
+
+#: ../roundup/roundupdb.py:147
+#: ../roundup/roundupdb.py:142
+msgid "files"
+msgstr "Dateien"
+
+#: ../roundup/roundupdb.py:147
+#: ../roundup/roundupdb.py:141
+msgid "messages"
+msgstr "Kommentare"
+
+#: ../roundup/roundupdb.py:147
+#: ../roundup/roundupdb.py:141
+msgid "nosy"
+msgstr "Interessenten"
+
+#: ../roundup/roundupdb.py:147
+#: ../roundup/roundupdb.py:141
+msgid "superseder"
+msgstr "ersetzt durch"
+
+#: ../roundup/roundupdb.py:147
+#: ../roundup/roundupdb.py:141
+msgid "title"
+msgstr "Titel"
+
+#: ../roundup/roundupdb.py:148
+#: ../roundup/roundupdb.py:142
+msgid "assignedto"
+msgstr "zugewiesen"
+
+#: ../roundup/roundupdb.py:148
+msgid "keyword"
+msgstr ""
+
+#: ../roundup/roundupdb.py:148
+#: ../roundup/roundupdb.py:142
+msgid "priority"
+msgstr "Prioriät"
+
+#: ../roundup/roundupdb.py:148
+#: ../roundup/roundupdb.py:142
+msgid "status"
+msgstr "Status"
+
+#: ../roundup/roundupdb.py:142
+#, (old key, replaced by "keyword")
+msgid "topic"
+msgstr "Schlagwort"
+
+#: ../roundup/roundupdb.py:151
+#: ../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:151
+#: ../roundup/roundupdb.py:145
+msgid "actor"
+msgstr "Akteur"
+
+#: ../roundup/roundupdb.py:151
+#: ../roundup/roundupdb.py:145
+msgid "creation"
+msgstr "Erstellungsdatum"
+
+#: ../roundup/roundupdb.py:151
+#: ../roundup/roundupdb.py:145
+msgid "creator"
+msgstr "Ersteller"
+
+#: ../roundup/roundupdb.py:309
+#: ../roundup/roundupdb.py:304
+#, python-format
+msgid "New submission from %(authname)s%(authaddr)s:"
+msgstr ""
+
+#: ../roundup/roundupdb.py:312
+#: ../roundup/roundupdb.py:307
+#, python-format
+msgid "%(authname)s%(authaddr)s added the comment:"
+msgstr "%(authname)s%(authaddr)s merkte an:"
+
+#: ../roundup/roundupdb.py:315
+#, python-format
+msgid "Change by %(authname)s%(authaddr)s:"
+msgstr ""
+"Änderung von %(authname)s%(authaddr)s:"
+
+#: ../roundup/roundupdb.py:342
+#, python-format
+msgid "File '%(filename)s' not attached - you can download it from %(link)s."
+msgstr ""
+"Die Datei '%(filename)s' ist nicht beigefügt - Sie können Sie unter\n"
+"%(link)s herunterladen."
+
+#: ../roundup/roundupdb.py:615
+#, python-format
+msgid ""
+"\n"
+"Now:\n"
+"%(new)s\n"
+"Was:\n"
+"%(old)s"
+msgstr ""
+"\n"
+"Jetzt:\n"
+"%(new)s\n"
+"Vorher:\n"
+"%(old)s"
+
+#: ../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 class] [[-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@server\n"
+" The username and password may be omitted:\n"
+"    pop username@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"
+"POPS:\n"
+" Connect to a POP server over ssl. This requires python 2.4 or later.\n"
+" This supports the same notation as POP.\n"
+"\n"
+"APOP:\n"
+" Same as POP, but using Authenticated POP:\n"
+"    apop username:password@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@server\n"
+" It also allows you to specify a specific mailbox other than INBOX using\n"
+" this format:\n"
+"    imap username:password@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@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 Eintrags (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 Eintrags 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 E-Mail.\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 E-Mail-Konten für einen Tracker verwenden und\n"
+"unterschiedliche Eintragstypen aus den Nachrichten erstellen.\n"
+"\n"
+"PIPE:\n"
+" Das Mail-Gateway liest eine Nachricht von der Standardeingabe und\n"
+" übergibt sie an das Modul roundup.mailgw.\n"
+"\n"
+"UNIX Mailbox:\n"
+" Die angegebene Mailbox-Datei wird ausgelesen, und alle Nachrichten werden\n"
+" an das Modul roundup.mailgw übergeben. Nach erfolgreicher Verarbeitung \n"
+" wird die Mail-Spooldatei geleert.\n"
+" Die Mailbox-Datei wird folgendermaßen angegeben:  mailbox /pfad/zur/mailbox\n"
+"\n"
+"POP:\n"
+" Das Gateway liest alle Nachrichten vom POP3-Konto und leitet sie weiter an \n"
+" das Modul roundup.mailgw. \n"
+" Das Konto wird folgendermaßen angegeben:\n"
+"    pop benutzername:passwort@server\n"
+" Benutzername und Passwort können weggelassen werden:\n"
+"    pop benutzername@server\n"
+"    pop server\n"
+" In diesem Fall werden die Anmeldungs-Daten zur Laufzeit erfragt.\n"
+"\n"
+"POPS:\n"
+" Mit einem POP3-Server über SSL verbinden; dies erfordert Python 2.4 oder\n"
+" neuer. Argumente wie bei POP.\n"
+"\n"
+"APOP:\n"
+" Wie POP, aber unter Verwendung von authentifiziertem POP:\n"
+"    apop benutzername:passwort@server\n"
+"\n"
+"IMAP:\n"
+" Verbindung mit einem IMAP-Server. Die Syntax entspricht der POP-\n"
+" Spezifikation:\n"
+"    imap benutzername:passwort@server\n"
+" Um eine andere Mailbox anstelle von \"INBOX\" zu verwenden, benutzen Sie:\n"
+"    imap benutzername:passwort@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@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 E-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 E-Mail-Quelle muss \"mailbox\", \"pop\", \"apop\", \"imap\" oder \"imaps\" gewählt werden"
+
+#: ../roundup/scripts/roundup_server.py:76
+msgid "WARNING: generating temporary SSL certificate"
+msgstr ""
+"WARNUNG: erzeuge temporäres SSL-Zertifikat"
+
+#: ../roundup/scripts/roundup_server.py:253
+#: ../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 Modul grp 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-Konto (\"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 Modul pwd 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 Multiprozessmodus \"%s\" ist nicht verfügbar, Einprozessmodus 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:695
+#: ../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 Benutzernummer\n"
+" -g <GID>      Startet den Roundup-Server mit dieser Gruppennummer\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:702
+#: ../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"
+" -i <fname>    set tracker index template\n"
+" -s            enable SSL\n"
+" -e <fname>    PEM file containing SSL key and certificate\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)sBenutzung: 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            Hostnamen 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>    Multiprozessmodus (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_HOME 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-empty.html:6
+msgid "Please specify your search parameters!"
+msgstr "Bitte geben Sie Ihre Suchparameter an!"
+
+#: ../templates/classic/html/_generic.help-list.html:20
+#: ../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:35
+#: ../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:35
+#: ../templates/minimal/html/user.register.html:14
+msgid "You are not allowed to view this page."
+msgstr "Sie sind nicht berechtigt, diese Seite anzuzeigen."
+
+#: ../templates/classic/html/_generic.help-list.html:34
+msgid "1..25 out of 50"
+msgstr ""
+"1..25 von 50"
+
+#: ../templates/classic/html/_generic.help-search.html:9
+msgid ""
+"Generic template ${template} or version for class ${classname} is not yet "
+"implemented"
+msgstr ""
+"Die generische Vorlage ${template} bzw. die Version für die Klasse ${classname} "
+"ist noch nicht implementiert"
+
+#: ../templates/classic/html/_generic.help-submit.html:57
+#: ../templates/classic/html/_generic.help.html:31
+#: ../templates/minimal/html/_generic.help.html:31
+#: ../templates/classic/html/_generic.help.html:30
+#: ../templates/minimal/html/_generic.help.html:30
+msgid " Cancel "
+msgstr " Abbrechen "
+
+#: ../templates/classic/html/_generic.help-submit.html:63
+#: ../templates/classic/html/_generic.help.html:34
+#: ../templates/minimal/html/_generic.help.html:34
+#: ../templates/classic/html/_generic.help.html:33
+#: ../templates/minimal/html/_generic.help.html:33
+msgid " Apply "
+msgstr " Anwenden "
+
+#: ../templates/classic/html/_generic.help.html:9
+#: ../templates/classic/html/user.help.html:13
+#: ../templates/minimal/html/_generic.help.html:9
+msgid "${property} help - ${tracker}"
+msgstr "Hilfe zu \"${property}\" - ${tracker}"
+
+#: ../templates/classic/html/_generic.help.html:41
+#: ../templates/classic/html/help.html:21
+#: ../templates/classic/html/issue.index.html:81
+#: ../templates/minimal/html/_generic.help.html:41
+#: ../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:53
+#: ../templates/classic/html/help.html:28
+#: ../templates/classic/html/issue.index.html:89
+#: ../templates/minimal/html/_generic.help.html:53
+#: ../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:57
+#: ../templates/classic/html/help.html:32
+#: ../templates/classic/html/issue.index.html:92
+#: ../templates/minimal/html/_generic.help.html:57
+#: ../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:19
+#: ../templates/classic/html/_generic.item.html:16
+#: ../templates/classic/html/file.item.html:13
+#: ../templates/classic/html/issue.index.html:20
+#: ../templates/classic/html/issue.item.html:32
+#: ../templates/classic/html/msg.item.html:30
+#: ../templates/classic/html/user.index.html:13
+#: ../templates/classic/html/user.item.html:39
+#: ../templates/minimal/html/_generic.index.html:19
+#: ../templates/minimal/html/_generic.item.html:17
+#: ../templates/minimal/html/user.index.html:13
+#: ../templates/minimal/html/user.item.html:39
+#: ../templates/minimal/html/user.register.html:17
+msgid "Please login with your username and password."
+msgstr ""
+
+#: ../templates/classic/html/_generic.index.html:28
+#: ../templates/minimal/html/_generic.index.html:28
+#: ../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 hier die Einträge der Klasse \"${classname}\" "
+"bearbeiten. <strong>Hinweise:</strong></p>"
+"<ul>"
+"<li> Kommata, Zeilenumbrüche und "
+"Anführungszeichen (\") mit Vorsicht verwenden:"
+"<ul><li> Kommata und Zeilenumbrüche "
+"dürfen nur in Anführungszeichen (\") verwendet werden."
+"<li> Um Anführungszeichen in "
+"Werten zu verwenden, müssen sie verdoppelt werden (<q><tt>\"\"</tt></q>)</ul>"
+"<li>Mehrfachlinks werden durch Doppelpunkt (<q><tt>:</tt></q>) getrennt "
+"<tt>(... ,\"eins:zwei:drei\", ...)</tt>"
+"<li> Einträge können "
+"gelöscht werden, indem Sie Zeilen entfernen."
+"<li>Für neue Einträge fügen Sie Zeilen ein; "
+"geben Sie bei der ID-Spalte ein großes <tt>X</tt> an. </ul><p>"
+
+#: ../templates/classic/html/_generic.index.html:50
+#: ../templates/minimal/html/_generic.index.html:50
+#: ../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:27
+#: ../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
+#: ../templates/classic/html/msg.item.html:48
+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:23
+#: ../templates/classic/html/file.item.html:18
+#: ../templates/classic/html/user.item.html:39
+#: ../templates/classic/html/user.register.html:17
+msgid "Name"
+msgstr "Name"
+
+#: ../templates/classic/html/file.item.html:45
+#: ../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:10
+msgid "List of issues"
+msgstr ""
+"Aufgabenliste"
+
+#: ../templates/classic/html/issue.index.html:6
+msgid "List of issues"
+msgstr "Aufgabenliste"
+
+#: ../templates/classic/html/issue.index.html:6
+msgid "List of issues - ${query} - ${tracker}"
+msgstr "Aufgabenliste - ${query} - ${tracker}"
+
+#: ../templates/classic/html/issue.index.html:6
+msgid "List of issues - ${query}"
+msgstr "Aufgabenliste - ${query}"
+
+#: ../templates/classic/html/issue.index.html:27
+#: ../templates/classic/html/issue.item.html:49
+#: ../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:28
+#: ../templates/classic/html/issue.index.html:18
+msgid "ID"
+msgstr "ID"
+
+#: ../templates/classic/html/issue.index.html:29
+#: ../templates/classic/html/issue.index.html:19
+msgid "Creation"
+msgstr "Erstellungsdatum"
+
+#: ../templates/classic/html/issue.index.html:30
+#: ../templates/classic/html/issue.index.html:20
+msgid "Activity"
+msgstr "Aktivität"
+
+#: ../templates/classic/html/issue.index.html:31
+#: ../templates/classic/html/issue.index.html:21
+msgid "Actor"
+msgstr "Akteur"
+
+#: ../templates/classic/html/issue.index.html:32
+#: ../templates/classic/html/keyword.item.html:37
+msgid "Keyword"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:22
+#, (old key, topic replaced by "keyword")
+#, Schlagwort <-> verschlagworten. Fachbegriff; besser als Stichwort oder Thema!
+msgid "Topic"
+msgstr "Schlagwort"
+
+#: ../templates/classic/html/issue.index.html:33
+#: ../templates/classic/html/issue.item.html:44
+#: ../templates/classic/html/issue.index.html:23
+#: ../templates/classic/html/issue.item.html:33
+msgid "Title"
+msgstr "Titel"
+
+#: ../templates/classic/html/issue.index.html:34
+#: ../templates/classic/html/issue.item.html:51
+#: ../templates/classic/html/issue.index.html:24
+#: ../templates/classic/html/issue.item.html:40
+msgid "Status"
+msgstr "Status"
+
+#: ../templates/classic/html/issue.index.html:35
+#: ../templates/classic/html/issue.index.html:25
+msgid "Creator"
+msgstr "Ersteller"
+
+#: ../templates/classic/html/issue.index.html:36
+#: ../templates/classic/html/issue.index.html:26
+msgid "Assigned&nbsp;To"
+msgstr "Zugewiesen"
+
+#: ../templates/classic/html/issue.index.html:105
+#: ../templates/classic/html/issue.index.html:90
+msgid "Download as CSV"
+msgstr "Als CSV-Datei herunterladen"
+
+#: ../templates/classic/html/issue.index.html:115
+#: ../templates/classic/html/issue.index.html:98
+msgid "Sort on:"
+msgstr "Sortieren:"
+
+#: ../templates/classic/html/issue.index.html:119
+#: ../templates/classic/html/issue.index.html:140
+#: ../templates/classic/html/issue.index.html:101
+#: ../templates/classic/html/issue.index.html:118
+msgid "- nothing -"
+msgstr "- nichts -"
+
+#: ../templates/classic/html/issue.index.html:127
+#: ../templates/classic/html/issue.index.html:148
+#: ../templates/classic/html/issue.index.html:109
+#: ../templates/classic/html/issue.index.html:126
+msgid "Descending:"
+msgstr "Absteigend:"
+
+#: ../templates/classic/html/issue.index.html:136
+#: ../templates/classic/html/issue.index.html:115
+msgid "Group on:"
+msgstr "Gruppieren:"
+
+#: ../templates/classic/html/issue.index.html:155
+#: ../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:56
+#: ../templates/classic/html/issue.item.html:45
+msgid "Superseder"
+msgstr "Ersetzt durch"
+
+#: ../templates/classic/html/issue.item.html:61
+msgid "View:"
+msgstr "Anzeigen:"
+
+#: ../templates/classic/html/issue.item.html:50
+msgid "View: ${link}"
+msgstr "Anzeigen: ${link}"
+
+#: ../templates/classic/html/issue.item.html:67
+#: ../templates/classic/html/issue.item.html:54
+msgid "Nosy List"
+msgstr "Interessenten"
+
+#: ../templates/classic/html/issue.item.html:76
+#: ../templates/classic/html/issue.item.html:63
+msgid "Assigned To"
+msgstr "Zugewiesen"
+
+#: ../templates/classic/html/issue.item.html:78
+#: ../templates/classic/html/page.html:103
+#: ../templates/minimal/html/page.html:102
+msgid "Keywords"
+msgstr "Schlagwörter"
+
+#: ../templates/classic/html/issue.item.html:65
+msgid "Topics"
+msgstr "Schlagwörter"
+
+#: ../templates/classic/html/issue.item.html:86
+#: ../templates/classic/html/issue.item.html:73
+msgid "Change Note"
+msgstr "Änderungsnotiz"
+
+#: ../templates/classic/html/issue.item.html:94
+#: ../templates/classic/html/issue.item.html:81
+msgid "File"
+msgstr "Datei"
+
+#: ../templates/classic/html/issue.item.html:106
+#: ../templates/classic/html/issue.item.html:99
+msgid "Make a copy"
+msgstr "kopieren"
+
+#: ../templates/classic/html/issue.item.html:100
+#: ../templates/classic/html/issue.item.html:114
+#: ../templates/classic/html/user.item.html:153
+#: ../templates/classic/html/user.register.html:69
+#: ../templates/minimal/html/user.item.html:153
+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>Achtung:&nbsp;</td> <th class=\"required"
+"\">Fett markierte</th> <td>&nbsp;Felder sind immer auszufüllen. </td> </tr> "
+"</table>"
+
+#: ../templates/classic/html/issue.item.html:128
+#: ../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:132
+#: ../templates/classic/html/msg.item.html:61
+#: ../templates/classic/html/issue.item.html:118
+#: ../templates/classic/html/msg.item.html:51
+msgid "Files"
+msgstr "Dateien"
+
+#: ../templates/classic/html/issue.item.html:134
+#: ../templates/classic/html/msg.item.html:63
+#: ../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:135
+#: ../templates/classic/html/msg.item.html:64
+#: ../templates/classic/html/issue.item.html:121
+#: ../templates/classic/html/msg.item.html:54
+msgid "Uploaded"
+msgstr "Hochgeladen"
+
+#: ../templates/classic/html/issue.item.html:136
+#: ../templates/classic/html/issue.item.html:122
+msgid "Type"
+msgstr "Typ"
+
+#: ../templates/classic/html/issue.item.html:137
+#: ../templates/classic/html/issue.item.html:123
+#: ../templates/classic/html/query.edit.html:30
+msgid "Edit"
+msgstr "bearbeiten"
+
+#: ../templates/classic/html/issue.item.html:138
+#: ../templates/classic/html/issue.item.html:124
+msgid "Remove"
+msgstr "verbergen"
+
+#: ../templates/classic/html/issue.item.html:158
+#: ../templates/classic/html/issue.item.html:179
+#: ../templates/classic/html/issue.item.html:144
+#: ../templates/classic/html/issue.item.html:165
+#: ../templates/classic/html/query.edit.html:50
+msgid "remove"
+msgstr "verbergen"
+
+#: ../templates/classic/html/issue.item.html:165
+#: ../templates/classic/html/issue.item.html:151
+#: ../templates/classic/html/msg.index.html:9
+msgid "Messages"
+msgstr "Kommentare"
+
+#: ../templates/classic/html/issue.item.html:169
+#: ../templates/classic/html/issue.item.html:155
+msgid "msg${id} (view)"
+msgstr "Kommentar msg${id} (betrachten)"
+
+#: ../templates/classic/html/issue.item.html:170
+#: ../templates/classic/html/issue.item.html:156
+msgid "Author: ${author}"
+msgstr "Autor: ${author}"
+
+#: ../templates/classic/html/issue.item.html:172
+#: ../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:31
+#: ../templates/classic/html/issue.search.html:25
+msgid "Filter on"
+msgstr "Filtern"
+
+#: ../templates/classic/html/issue.search.html:32
+#: ../templates/classic/html/issue.search.html:26
+msgid "Display"
+msgstr "anzeigen"
+
+#: ../templates/classic/html/issue.search.html:33
+#: ../templates/classic/html/issue.search.html:27
+msgid "Sort on"
+msgstr "sortieren"
+
+#: ../templates/classic/html/issue.search.html:34
+#: ../templates/classic/html/issue.search.html:28
+msgid "Group on"
+msgstr "gruppieren"
+
+#: ../templates/classic/html/issue.search.html:38
+#: ../templates/classic/html/issue.search.html:32
+msgid "All text*:"
+msgstr "Volltext*:"
+
+#: ../templates/classic/html/issue.search.html:46
+#: ../templates/classic/html/issue.search.html:40
+msgid "Title:"
+msgstr "Titel:"
+
+#: ../templates/classic/html/issue.search.html:56
+msgid "Keyword:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:50
+#, (old key, topic replaced by "keyword")
+msgid "Topic:"
+msgstr "Schlagwort:"
+
+#: ../templates/classic/html/issue.search.html:58
+#: ../templates/classic/html/issue.search.html:123
+#: ../templates/classic/html/issue.search.html:139
+#: ../templates/classic/html/issue.search.html:114
+#: ../templates/classic/html/issue.search.html:130
+msgid "not selected"
+msgstr ""
+"nicht ausgewählt"
+
+#: ../templates/classic/html/issue.search.html:67
+msgid "ID:"
+msgstr "ID:"
+
+#: ../templates/classic/html/issue.search.html:75
+#: ../templates/classic/html/issue.search.html:66
+msgid "Creation Date:"
+msgstr "Erstellungsdatum:"
+
+#: ../templates/classic/html/issue.search.html:86
+#: ../templates/classic/html/issue.search.html:77
+msgid "Creator:"
+msgstr "Ersteller:"
+
+#: ../templates/classic/html/issue.search.html:88
+#: ../templates/classic/html/issue.search.html:79
+msgid "created by me"
+msgstr "von mir erstellt"
+
+#: ../templates/classic/html/issue.search.html:97
+#: ../templates/classic/html/issue.search.html:88
+msgid "Activity:"
+msgstr "Aktivität:"
+
+#: ../templates/classic/html/issue.search.html:108
+#: ../templates/classic/html/issue.search.html:99
+msgid "Actor:"
+msgstr "Akteur:"
+
+#: ../templates/classic/html/issue.search.html:110
+#: ../templates/classic/html/issue.search.html:101
+msgid "done by me"
+msgstr "von mir zuletzt geändert"
+
+#: ../templates/classic/html/issue.search.html:121
+#: ../templates/classic/html/issue.search.html:112
+msgid "Priority:"
+msgstr "Priorität:"
+
+#: ../templates/classic/html/issue.search.html:134
+#: ../templates/classic/html/issue.search.html:125
+msgid "Status:"
+msgstr "Status:"
+
+#: ../templates/classic/html/issue.search.html:137
+#: ../templates/classic/html/issue.search.html:128
+msgid "not resolved"
+msgstr "ungelöst"
+
+#: ../templates/classic/html/issue.search.html:152
+#: ../templates/classic/html/issue.search.html:143
+msgid "Assigned to:"
+msgstr "Zugewiesen:"
+
+#: ../templates/classic/html/issue.search.html:155
+#: ../templates/classic/html/issue.search.html:146
+msgid "assigned to me"
+msgstr "mir zugewiesen"
+
+#: ../templates/classic/html/issue.search.html:157
+#: ../templates/classic/html/issue.search.html:148
+msgid "unassigned"
+msgstr "nicht zugewiesen"
+
+#: ../templates/classic/html/issue.search.html:167
+#: ../templates/classic/html/issue.search.html:158
+msgid "No Sort or group:"
+msgstr ""
+"Nicht sortieren/gruppieren:"
+
+#: ../templates/classic/html/issue.search.html:175
+#: ../templates/classic/html/issue.search.html:166
+msgid "Pagesize:"
+msgstr "Einträge/Seite:"
+
+#: ../templates/classic/html/issue.search.html:181
+#: ../templates/classic/html/issue.search.html:164
+msgid "Start With:"
+msgstr "Starten bei:"
+
+#: ../templates/classic/html/issue.search.html:187
+#: ../templates/classic/html/issue.search.html:170
+msgid "Sort Descending:"
+msgstr "Absteigend sortieren:"
+
+#: ../templates/classic/html/issue.search.html:194
+#: ../templates/classic/html/issue.search.html:177
+msgid "Group Descending:"
+msgstr "Absteigend gruppieren:"
+
+#: ../templates/classic/html/issue.search.html:201
+#: ../templates/classic/html/issue.search.html:184
+msgid "Query name**:"
+msgstr "Speichern unter**:"
+
+#: ../templates/classic/html/issue.search.html:213
+#: ../templates/classic/html/page.html:43
+#: ../templates/classic/html/page.html:92
+#: ../templates/classic/html/page.html:31
+#: ../templates/classic/html/page.html:60
+#: ../templates/classic/html/user.help-search.html:69
+#: ../templates/classic/html/issue.search.html:204
+#: ../templates/minimal/html/page.html:43
+#: ../templates/minimal/html/page.html:91
+#: ../templates/minimal/html/page.html:31
+msgid "Search"
+msgstr "Suchen"
+
+#: ../templates/classic/html/issue.search.html:218
+#: ../templates/classic/html/issue.search.html:209
+msgid "*: The \"all text\" field will look in message bodies and issue titles"
+msgstr ""
+"*: Das Feld \"Volltext\" durchsucht Titel von Aufgaben und Kommentartexte"
+
+#: ../templates/classic/html/issue.search.html:221
+#: ../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 ""
+"**: 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 "Schlagwort bearbeiten - ${tracker}"
+
+#: ../templates/classic/html/keyword.item.html:5
+msgid "Keyword editing"
+msgstr "Schlagwörter bearbeiten"
+
+#: ../templates/classic/html/keyword.item.html:11
+msgid "Existing Keywords"
+msgstr "Vorhandene Schlagwörter"
+
+#: ../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 Schlagwort 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 Schlagwort hinzufügen, tragen Sie es hier ein und klicken Sie "
+"auf \"Eintrag speichern\"."
+
+#: ../templates/classic/html/msg.index.html:3
+msgid "List of messages - ${tracker}"
+msgstr "Kommentare - ${tracker}"
+
+#: ../templates/classic/html/msg.index.html:5
+msgid "Message listing"
+msgstr "Kommentare"
+
+#: ../templates/classic/html/msg.item.html:6
+msgid "Message ${id} - ${tracker}"
+msgstr "Kommentar ${id} - ${tracker}"
+
+#: ../templates/classic/html/msg.item.html:9
+msgid "New Message - ${tracker}"
+msgstr "Neuer Kommentar - ${tracker}"
+
+#: ../templates/classic/html/msg.item.html:13
+msgid "New Message"
+msgstr "Neuer Kommentar"
+
+#: ../templates/classic/html/msg.item.html:15
+msgid "New Message Editing"
+msgstr "Neuen Kommentar bearbeiten"
+
+#: ../templates/classic/html/msg.item.html:18
+msgid "Message${id}"
+msgstr "Kommentar ${id}"
+
+#: ../templates/classic/html/msg.item.html:21
+msgid "Message${id} Editing"
+msgstr "Kommentar ${id} bearbeiten"
+
+#: ../templates/classic/html/msg.item.html:38
+#: ../templates/classic/html/msg.item.html:28
+msgid "Author"
+msgstr "Autor"
+
+#: ../templates/classic/html/msg.item.html:43
+#: ../templates/classic/html/msg.item.html:33
+msgid "Recipients"
+msgstr "Empfänger"
+
+#: ../templates/classic/html/msg.item.html:54
+#: ../templates/classic/html/msg.item.html:44
+msgid "Content"
+msgstr "Inhalt"
+
+#: ../templates/classic/html/page.html:54
+#: ../templates/minimal/html/page.html:53
+#: ../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:65
+#: ../templates/minimal/html/page.html:64
+#: ../templates/classic/html/page.html:39
+msgid "Issues"
+msgstr "Aufgaben"
+
+#: ../templates/classic/html/page.html:67
+#: ../templates/classic/html/page.html:105
+#: ../templates/minimal/html/page.html:66
+#: ../templates/minimal/html/page.html:104
+#: ../templates/classic/html/page.html:41
+#: ../templates/classic/html/page.html:60
+msgid "Create New"
+msgstr "neuer Eintrag"
+
+#: ../templates/classic/html/page.html:69
+#: ../templates/minimal/html/page.html:68
+#: ../templates/classic/html/page.html:43
+msgid "Show Unassigned"
+msgstr "nicht zugewiesen"
+
+#: ../templates/classic/html/page.html:81
+#: ../templates/minimal/html/page.html:80
+#: ../templates/classic/html/page.html:45
+msgid "Show All"
+msgstr "alle anzeigen"
+
+#: ../templates/classic/html/page.html:93
+#: ../templates/minimal/html/page.html:92
+#: ../templates/classic/html/page.html:48
+msgid "Show issue:"
+msgstr "Aufgabe anzeigen:"
+
+#: ../templates/classic/html/page.html:58
+msgid "Keywords"
+msgstr "Schlagwörter"
+# siehe die Anmerkung zu "Schlagwort"
+
+#: ../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:104
+#: ../templates/minimal/html/page.html:45
+msgid "Remember me?"
+msgstr "dauerhaft anmelden?"
+
+#: ../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 "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 "Hallo, ${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
+#: ../templates/classic/html/page.html:136
+#: ../templates/minimal/html/page.html:81
+msgid "diese Nachricht löschen"
+msgstr ""
+
+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 bearbeiten - ${tracker}"
+
+#: ../templates/classic/html/query.edit.html:6
+msgid "\"Your Queries\" Editing"
+msgstr "Abfragen bearbeiten"
+
+#: ../templates/classic/html/query.edit.html:11
+msgid "You are not allowed to edit queries."
+msgstr "Sie sind nicht berechtigt, 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 "weglassen"
+
+#: ../templates/classic/html/query.edit.html:45
+msgid "include"
+msgstr "anfügen"
+
+#: ../templates/classic/html/query.edit.html:49
+msgid "leave in"
+msgstr "belassen"
+
+#: ../templates/classic/html/query.edit.html:54
+msgid "[query is retired]"
+msgstr "[Abfrage ist verborgen]"
+
+#: ../templates/classic/html/query.edit.html:67
+#: ../templates/classic/html/query.edit.html:94
+#: ../templates/classic/html/query.edit.html:92
+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:96
+#: ../templates/classic/html/query.edit.html:90
+msgid "[not yours to edit]"
+msgstr "[nicht Ihr Eintrag]"
+
+#: ../templates/classic/html/query.edit.html:104
+#: ../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 E-Mail-Adresse an, mit der Sie sich registriert haben..."
+
+#: ../templates/classic/html/user.forgotten.html:16
+msgid "Email Address:"
+msgstr "E-Mail-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 eine Bestätigungs-E-Mail verschickt. Bitte folgen Sie den Anweisungen darin, um ihr Passwort zurückzusetzen."
+
+#: ../templates/classic/html/user.help-search.html:73
+msgid "Pagesize"
+msgstr "Einträge/Seite"
+
+#: ../templates/classic/html/user.help.html:43
+msgid ""
+"Your browser is not capable of using frames; you should be redirected "
+"immediately, or visit ${link}."
+msgstr ""
+
+#: ../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 "E-Mail-Adresse"
+
+#: ../templates/classic/html/user.index.html:18
+msgid "Phone number"
+msgstr "Telefonnummer"
+
+#: ../templates/classic/html/user.index.html:19
+msgid "Retire"
+msgstr "Entfernen"
+
+#: ../templates/classic/html/user.index.html:32
+msgid "retire"
+msgstr "entfernen"
+
+#: ../templates/classic/html/user.item.html:7
+#: ../templates/minimal/html/user.item.html:7
+msgid "User ${id}: ${title} - ${tracker}"
+msgstr "Benutzer ${id}: ${title} - ${tracker}"
+
+#: ../templates/classic/html/user.item.html:10
+#: ../templates/minimal/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: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 "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:61
+#: ../templates/minimal/html/user.item.html:58
+msgid "(to give the user more than one role, enter a comma,separated,list)"
+msgstr ""
+"<tt>Verwenden,Sie,Kommata</tt>, 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 "(als Differenz zu GMT/UTC in Stunden - Voreinstellung: ${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 "E-Mail-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 "<div title=\"alle, von denen E-Mails an den Bugtracker geschickt werden sollen\">Alternative E-Mail-Adressen</div><i>(eine pro Zeile)</i>"
+
+#: ../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.register.html:21
+#: ../templates/minimal/html/user.register.html:29
+#: ../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 "Benutzername"
+
+#: ../templates/classic/html/user.item.html:42
+#: ../templates/classic/html/user.register.html:25
+#: ../templates/minimal/html/user.register.html:33
+#: ../templates/minimal/html/user.item.html:31
+#: ../templates/minimal/html/user.register.html:30
+msgid "Login Password"
+msgstr "Paßwort"
+
+#: ../templates/classic/html/user.item.html:46
+#: ../templates/classic/html/user.register.html:29
+#: ../templates/minimal/html/user.register.html:37
+#: ../templates/minimal/html/user.item.html:35
+#: ../templates/minimal/html/user.register.html:34
+msgid "Confirm Password"
+msgstr "Paßwort bestätigen"
+
+#: ../templates/classic/html/user.register.html:41
+#: ../templates/classic/html/user.item.html:61
+#: ../templates/classic/html/user.register.html:41
+msgid "Phone"
+msgstr "Telefon"
+
+#: ../templates/classic/html/user.item.html:83
+#: ../templates/classic/html/user.register.html:49
+#: ../templates/minimal/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-Adresse"
+
+#: ../templates/classic/html/user.rego_progress.html:4
+#: ../templates/minimal/html/user.rego_progress.html:4
+msgid "Registration in progress - ${tracker}"
+msgstr "Die Registrierung ist im Gange - ${tracker}"
+
+#: ../templates/classic/html/user.rego_progress.html:6
+#: ../templates/minimal/html/user.rego_progress.html:6
+msgid "Registration in progress..."
+msgstr "Die Registrierung ist im 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 eine Bestätigungs-E-Mail erhalten. Um die Registrierung abzuschließen, klicken Sie auf den enthaltenen Link."
+
+#: ../templates/minimal/html/page.html:38
+msgid "Hello,<br>${user}"
+msgstr "Hallo,<br>${user}"
+
+#: ../templates/minimal/html/user.item.html:3
+msgid "User editing - ${tracker}"
+msgstr "Benutzer bearbeiten - ${tracker}"
+
+#: ../templates/classic/html/issue.item.html:120;
+msgid "Copy item"
+msgstr "kopieren"
+
+#: ../templates/classic/html/...:123
+msgid "No Sort or group"
+msgstr "nicht sortieren/gruppieren"
+
+#: ../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:170
+msgid "don't sort"
+msgstr "nicht sortieren"
+
+#: ../templates/classic/html/issue.search.html:177
+msgid "don't group"
+msgstr "nicht gruppieren"
+
+#: ../templates/classic(th)/html/issue.search.html:XXX
+msgid "Sort/Group Descending:"
+msgstr "absteigend sortieren/gruppieren:"
+
+#: ../templates/classic(th)/html/issue.search.html:XXX
+msgid "Paged Output:"
+msgstr "seitenweise ausgeben:"
+
+#: ../templates/classic/html/user.item.html:XXX
+msgid "username"
+msgstr "Benutzername"
+
+#: ../templates/classic/html/user.item.html:XXX
+msgid "realname"
+msgstr "Name"
+
+#: ../templates/classic/html/user.item.html:XXX
+msgid "firstname"
+msgstr "Vorname"
+
+#: ../templates/classic/html/user.item.html:XXX
+msgid "lastname"
+msgstr "Nachname"
+
+#: ../templates/classic/html/user.item.html:XXX
+msgid "address"
+msgstr "Mail-Adresse"
+
+# priority translations:
+#: ../templates/classic/initial_data.py:5
+msgid "critical"
+msgstr "Fehler (KRITISCH)"
+
+#: ../templates/classic/initial_data.py:6
+msgid "urgent"
+msgstr "Fehler (dringend)"
+
+#: ../templates/classic/initial_data.py:7
+msgid "bug"
+msgstr "Fehler"
+
+#: ../templates/classic/initial_data.py:8
+msgid "feature"
+msgstr "Anforderung"
+
+#: ../templates/classic/initial_data.py:9
+msgid "wish"
+msgstr "Wunsch"
+
+#: status translations:
+#: ../templates/classic/initial_data.py:12
+msgid "unread"
+msgstr "ungelesen"
+
+#: ../templates/classic/initial_data.py:13
+msgid "deferred"
+msgstr "zurückgestellt"
+
+#: ../templates/classic/initial_data.py:14
+msgid "chatting"
+msgstr "in Diskussion"
+
+#: ../templates/classic/initial_data.py:15
+#: ../templates/classic/initial_data.py:16
+msgid "need-eg"
+msgstr "Beispiel erbeten"
+
+#: ../templates/classic/initial_data.py:16
+#: ../templates/classic/initial_data.py:15
+msgid "in-progress"
+msgstr "in Arbeit"
+
+#: ../templates/classic/initial_data.py:17
+msgid "testing"
+msgstr "im Test"
+
+#: ../templates/classic/initial_data.py:18
+msgid "done-cbb"
+msgstr "erledigt (provisorisch)"
+
+#: ../templates/classic/initial_data.py:19
+msgid "resolved"
+msgstr "erledigt"
+
+#: ../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/classic(th)/html/issue.item.html
+msgid "(cal)"
+msgstr "(Kal.)"
diff --git a/locale/en.po b/locale/en.po
new file mode 100644 (file)
index 0000000..8807fc9
--- /dev/null
@@ -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@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"
diff --git a/locale/es.po b/locale/es.po
new file mode 100755 (executable)
index 0000000..496c313
--- /dev/null
@@ -0,0 +1,3693 @@
+# 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@ADDRESS>, YEAR.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Roundup 1.3.3\n"
+"Report-Msgid-Bugs-To: roundup-devel@lists.sourceforge.net\n"
+"POT-Creation-Date: 2007-09-16 09:48+0300\n"
+"PO-Revision-Date: 2007-09-18 01:22-0300\n"
+"Last-Translator: Ramiro Morales <rm0@gmx.net>\n"
+"Language-Team: Spanish Translators <roundup-devel@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:86 ../roundup/admin.py:989 ../roundup/admin.py:1040
+#: ../roundup/admin.py:1063 ../roundup/admin.py:86:989 :1040:1063
+#, python-format
+msgid "no such class \"%(classname)s\""
+msgstr "la clase \"%(classname)s\" no existe"
+
+# ../roundup/admin.py:95 :99
+#: ../roundup/admin.py:96 ../roundup/admin.py:100 ../roundup/admin.py:96:100
+#, python-format
+msgid "argument \"%(arg)s\" not propname=value"
+msgstr "el argumento \"%(arg)s\" no es de la forma nombrepropiedad=valor"
+
+#: ../roundup/admin.py:113
+#, python-format
+msgid ""
+"Problem: %(message)s\n"
+"\n"
+msgstr ""
+"Problema: %(message)s\n"
+"\n"
+
+#: ../roundup/admin.py:114
+#, 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"
+" -V                -- be verbose when importing\n"
+" -v                -- report Roundup and Python versions (and quit)\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"
+"                          a administrar\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\n"
+"                          listados. Equivalente a '-S \",\"'.\n"
+" -S <string>           -- separa los elementos con cadenas string cuando se\n"
+"                          generan listados.\n"
+" -s                    -- separa los elementos con espacios cuando se "
+"generan\n"
+"                          listados. Equivalente a '-S \" \"'.\n"
+" -V                    -- ser verborrágico cuando se importan datos\n"
+" -v                    -- reporta las versiones de Roundup y Python (y "
+"termina)\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:141
+msgid "Commands:"
+msgstr "Comandos:"
+
+#: ../roundup/admin.py:148
+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:178
+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.\n"
+"Este es simplemente la ruta al tracker roundup con el que se está "
+"trabajando.\n"
+"Un tracker roundup es donde roundup mantiene la base de datos y el fichero "
+"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, por ej.\n"
+"bug1, user10, ...\n"
+"\n"
+"Los valores de propiedades se representan como cadenas en argumentos de\n"
+"comandos y en resultados visualizados:\n"
+" . Las cadenas son, ahem, cadenas.\n"
+" . Los valores de fechas se imprimen en el formato de fecha completo y en "
+"el\n"
+"   huso 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\n"
+"   como argumentos, se aceptan tanto los designadores de nodos como las\n"
+"   cadenas clave.\n"
+" . Los valores Multilink se imprimen como listas de designadores de nodos\n"
+"   unidos por comas. Cuando se pasan como argumentos, se aceptan tanto los\n"
+"   designadores de nodos como las cadenas clave; tambien se aceptan una\n"
+"   cadena vacía, un nodo 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: línea nueva, retorno de carro y\n"
+"                             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 sobre comandos:\n"
+
+#: ../roundup/admin.py:241
+#, python-format
+msgid "%s:"
+msgstr "%s:"
+
+#: ../roundup/admin.py:246
+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:269
+#, 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:346 ../roundup/admin.py:402 ../roundup/admin.py:346:402
+msgid "Templates:"
+msgstr "Plantillas:"
+
+# ../roundup/admin.py:341 :398
+#: ../roundup/admin.py:349 ../roundup/admin.py:413 ../roundup/admin.py:349:413
+msgid "Back ends:"
+msgstr "Motor de almacenamiento"
+
+#: ../roundup/admin.py:352
+msgid ""
+"Usage: install [template [backend [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 and backend may be specified on the command-line\n"
+"        as arguments, in that order.\n"
+"\n"
+"        Command line arguments following the backend allows you to\n"
+"        pass initial values 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 ""
+"Uso:   install [plantilla [backend [clave=val[,clave=val]]]]\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 pueden especificarse en la línea\n"
+"        de comandos como argumentos, en ese orden.\n"
+"\n"
+"        El último argumento de la línea de comandos permite especificar "
+"valores\n"
+"        iniciales para opciones de configuración. Por ejemplo, "
+"especificando\n"
+"        \"web_http_auth=no,rdbms_user=dinsdale\" tendrá mayor "
+"preponderancia\n"
+"        que las opciones http_auth en la sección [web] y user en la\n"
+"        sección [rdbms]. Por favor tenga el cuidado de no usar espacios en\n"
+"        este argumento! (Rodee el argumento completo con comillas si "
+"necesita\n"
+"         usar espacios en los valores de opciones).\n"
+"\n"
+"        Luego de este comando debe usarse el comando initialise con el "
+"objetivo\n"
+"        de inicializar la base de datos del tracker. Puede editar los\n"
+"        contenidos iniciales de la base de datos del tracker antes de "
+"ejecutar\n"
+"        dicho comando editando la funcion init() del módulo dbinit.py del\n"
+"        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:375 ../roundup/admin.py:472 ../roundup/admin.py:533
+#: ../roundup/admin.py:612 ../roundup/admin.py:663 ../roundup/admin.py:721
+#: ../roundup/admin.py:742 ../roundup/admin.py:770 ../roundup/admin.py:842
+#: ../roundup/admin.py:909 ../roundup/admin.py:980 ../roundup/admin.py:1030
+#: ../roundup/admin.py:1053 ../roundup/admin.py:1084 ../roundup/admin.py:1180
+#: ../roundup/admin.py:1253 ../roundup/admin.py:375:472 :1030:1053 :1084:1180
+#: :1253 :533:612 :663:721 :742:770 :842:909:980
+msgid "Not enough arguments supplied"
+msgstr "No se proveyó una cantidad suficiente de argumentos"
+
+#: ../roundup/admin.py:381
+#, 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:389
+#, 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:404
+msgid "Select template [classic]: "
+msgstr "Seleccione la plantilla [classic]: "
+
+#: ../roundup/admin.py:415
+msgid "Select backend [anydbm]: "
+msgstr "Selecccione el motor de almacenamiento [anydbm]: "
+
+#: ../roundup/admin.py:425
+#, python-format
+msgid "Error in configuration settings: \"%s\""
+msgstr "Error en opciones de configuración: \"%s\""
+
+#: ../roundup/admin.py:434
+#, 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 fichero de configuración del tracker:\n"
+"   %(config_file)s"
+
+#: ../roundup/admin.py:444
+msgid " ... at a minimum, you must set following options:"
+msgstr " ... como mínimo, debe configurar las siguientes opciones:"
+
+#: ../roundup/admin.py:449
+#, 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 fichero de esquema:\n"
+"   %(database_config_file)s\n"
+" Puede también cambiar el fichero 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:467
+msgid ""
+"Usage: genconfig <filename>\n"
+"        Generate a new tracker config file (ini style) with default values\n"
+"        in <filename>.\n"
+"        "
+msgstr ""
+"Uso:   genconfig <fichero>\n"
+"        Genera un nuevo fichero de configuración de tracker (en formato "
+"ini)\n"
+"        con valores por defecto en el fichero <fichero>.\n"
+"        "
+
+#. password
+#: ../roundup/admin.py:477
+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\n"
+"        administrador.\n"
+"\n"
+"        Ejecuta la función de inicialización dbinit.init() del tracker\n"
+"        "
+
+#: ../roundup/admin.py:491
+msgid "Admin Password: "
+msgstr "Contraseña de administración: "
+
+#: ../roundup/admin.py:492
+msgid "       Confirm: "
+msgstr "       Confirmar: "
+
+#: ../roundup/admin.py:496
+msgid "Instance home does not exist"
+msgstr "El directorio base de la instancia no existe"
+
+#: ../roundup/admin.py:500
+msgid "Instance has not been installed"
+msgstr "La instancia no ha sido instalada"
+
+#: ../roundup/admin.py:505
+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:526
+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:566 ../roundup/admin.py:581 ../roundup/admin.py:566:581
+#, 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:589 ../roundup/admin.py:991 ../roundup/admin.py:1042
+#: ../roundup/admin.py:1065 ../roundup/admin.py:589:991 :1042:1065
+#, python-format
+msgid "no such %(classname)s node \"%(nodeid)s\""
+msgstr "no existe nodo de clase %(classname)s llamado  \"%(nodeid)s\""
+
+#: ../roundup/admin.py:591
+#, python-format
+msgid "no such %(classname)s property \"%(propname)s\""
+msgstr "no existe propiedad de clase %(classname)s llamado  \"%(propname)s\""
+
+#: ../roundup/admin.py:600
+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\n"
+"        designadores de ítems (\"designador[,designador,...]\") separados\n"
+"        por comas.\n"
+"\n"
+"        Este comando establece valores de las propiedades para todos los\n"
+"        designadores especificados. Si los valores no se especifican\n"
+"        (\"propiedad=\") entonces la propiedad se elimina. Si la propiedad\n"
+"        es del tipo multilink, deben especificarse los identificadores\n"
+"        asociados como números separados por comas (\"1,2,3\").\n"
+"        "
+
+#: ../roundup/admin.py:655
+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\n"
+"        propiedad.\n"
+"\n"
+"        Busca los nodos de la clase especificada que poseen un cierto valor "
+"de\n"
+"        propiedad. El valor puede ser el identificador de nodo del nodo\n"
+"        enlazado o su valor clave.\n"
+"        "
+
+# ../roundup/admin.py:675 :828 :840 :894
+#: ../roundup/admin.py:708 ../roundup/admin.py:862 ../roundup/admin.py:874
+#: ../roundup/admin.py:928 ../roundup/admin.py:708:862 :874:928
+#, python-format
+msgid "%(classname)s has no property \"%(propname)s\""
+msgstr "%(classname)s no posee la propiedad \"%(propname)s\""
+
+#: ../roundup/admin.py:715
+msgid ""
+"Usage: specification classname\n"
+"        Show the properties for a classname.\n"
+"\n"
+"        This lists the properties for a given class.\n"
+"        "
+msgstr ""
+"Uso:   especificación nombreclase\n"
+"        Muestra las propiedades para una clase nombreclase.\n"
+"\n"
+"        Visualiza las propiedades para una cierta clase.\n"
+"        "
+
+#: ../roundup/admin.py:730
+#, python-format
+msgid "%(key)s: %(value)s (key property)"
+msgstr "%(key)s: %(value)s (propiedad de clave)"
+
+#: ../roundup/admin.py:732
+#, python-format
+msgid "%(key)s: %(value)s"
+msgstr "%(key)s: %(value)s"
+
+#: ../roundup/admin.py:735
+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 propiedades para el/los nodo(s) especificado"
+"(s).\n"
+"        Lista las propiedades y sus valores asociados para el nodo "
+"especificado.\n"
+"        "
+
+#: ../roundup/admin.py:759
+#, python-format
+msgid "%(key)s: %(value)r"
+msgstr "%(key)s: %(value)r"
+
+#: ../roundup/admin.py:762
+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\n"
+"        nombre=valor provistos en la línea de comandos luego del comando\n"
+"        \"create\" para establecer valores de propiedad(es).        "
+
+#: ../roundup/admin.py:789
+#, python-format
+msgid "%(propname)s (Password): "
+msgstr "%(propname)s (Contraseña): "
+
+#: ../roundup/admin.py:791
+#, python-format
+msgid "   %(propname)s (Again): "
+msgstr "   %(propname)s (Nuevamente): "
+
+#: ../roundup/admin.py:793
+msgid "Sorry, try again..."
+msgstr "Lo lamento, intente nuevamente..."
+
+#: ../roundup/admin.py:797
+#, python-format
+msgid "%(propname)s (%(proptype)s): "
+msgstr "%(propname)s (%(proptype)s): "
+
+#: ../roundup/admin.py:815
+#, python-format
+msgid "you must provide the \"%(propname)s\" property."
+msgstr "debe proveer la propiedad \"%(propname)s\"."
+
+#: ../roundup/admin.py:827
+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\n"
+"        propiedad etiqueta es obtenida siguiendo el siguiente orden:\n"
+"        \"name\", \"title\" y luego la primera propiedad en orden\n"
+"        alfabético.\n"
+"\n"
+"        Cuando se usa -c, -S o -s imprime una lista de ids de items si no\n"
+"        se ha especificado propiedad. Si se ha especificado propiedad,\n"
+"        imprime una lista de dicha propiedad para cada instancia de la "
+"clase.\n"
+"        "
+
+#: ../roundup/admin.py:840
+msgid "Too many arguments supplied"
+msgstr "Demasiados argumentos"
+
+#: ../roundup/admin.py:876
+#, python-format
+msgid "%(nodeid)4s: %(value)s"
+msgstr "%(nodeid)4s: %(value)s"
+
+#: ../roundup/admin.py:880
+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\n"
+"        especifican las propiedades, se visualizan todas las propiedades.\n"
+"        Por omisión, los anchos de las columnas son iguales a los anchos\n"
+"        de valores mayores respectivos. Es posible definir el ancho de\n"
+"        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\n"
+"        etiqueta, deje un : al final sin un valor de ancho de la\n"
+"        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\n"
+"        caracteres.\n"
+"        "
+
+#: ../roundup/admin.py:924
+#, python-format
+msgid "\"%(spec)s\" not name:width"
+msgstr "\"%(spec)s\" no es de la forma nombre:longitud"
+
+#: ../roundup/admin.py:974
+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\n"
+"        designador.\n"
+"        "
+
+#: ../roundup/admin.py:995
+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\n"
+"        durante una sesión interactiva.\n"
+"\n"
+"        Los cambios realizados durante una sesión interactiva no son\n"
+"        automáticamente escritos en la base de datos - los mismos deben\n"
+"        ser escritos usando este comando\n"
+"\n"
+"        Los comandos individuales ejecutados desde la línea de comandos\n"
+"        son automáticamente escritos si resultan exitosos.\n"
+"        "
+
+#: ../roundup/admin.py:1010
+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\n"
+"        automáticamente escritos en la base de datos - los mismos deben\n"
+"        ser grabados manualmente. Este comando deshace todos dichos\n"
+"        cambios, de manera que un comando commit inmediatamente posterior\n"
+"        no introduciría cambios en la base de datos.\n"
+"        "
+
+#: ../roundup/admin.py:1023
+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\n"
+"        se usen los comandos list y find, y que su valor clave podrá ser\n"
+"        reusado.\n"
+"        "
+
+#: ../roundup/admin.py:1047
+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\n"
+"        para los usuarios.\n"
+"        "
+
+#. grab the directory to export to
+#: ../roundup/admin.py:1070
+msgid ""
+"Usage: export [[-]class[,class]] export_dir\n"
+"        Export the database to colon-separated-value files.\n"
+"        To exclude the files (e.g. for the msg or file class),\n"
+"        use the exporttables command.\n"
+"\n"
+"        Optionally limit the export to just the named classes\n"
+"        or exclude the named classes, if the 1st argument starts with '-'.\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]] dir_exportación\n"
+"        Exporta la base de datos a ficheros de valores separados por comas.\n"
+"        Para excluir los ficheros (por ej. en las clases msg o file),\n"
+"        use el comando exporttables.\n"
+"\n"
+"        Opcionalmente limita la exportación sólo a las clases especifica-\n"
+"        das o las excluye si el primer argumento comienza con '-'.\n"
+"\n"
+"        Esta acción exporta los datos actuales desde la base de datos a\n"
+"        ficheros de valores separados por comas que se colocarán en el\n"
+"        directorio de destino especificado (dir_exportación).\n"
+"        "
+
+#: ../roundup/admin.py:1145
+msgid ""
+"Usage: exporttables [[-]class[,class]] export_dir\n"
+"        Export the database to colon-separated-value files, excluding the\n"
+"        files below $TRACKER_HOME/db/files/ (which can be archived "
+"separately).\n"
+"        To include the files, use the export command.\n"
+"\n"
+"        Optionally limit the export to just the named classes\n"
+"        or exclude the named classes, if the 1st argument starts with '-'.\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]] dir_exportación\n"
+"        Exporta la base de datos a ficheros de valores separados por comas,\n"
+"        excluyendo los ficheros en $TRACKER_HOME/db/files/ (los cuales\n"
+"        pueden ser archivados por separado).\n"
+"\n"
+"        Opcionalmente limita la exportación sólo a las clases especifica-\n"
+"        das o las excluye si el primer argumento comienza con '-'.\n"
+"\n"
+"        Esta acción exporta los datos actuales desde la base de datos a\n"
+"        ficheros de valores separados por comas que se colocarán en el\n"
+"        directorio de destino especificado.\n"
+"        "
+
+#: ../roundup/admin.py:1160
+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 dir_importación\n"
+"        Importa una base de datos desde el directorio conteniendo ficheros\n"
+"        CSV, dos por cada clase a importar.\n"
+"\n"
+"        Los ficheros usados en la importación son:\n"
+"\n"
+"        <clase>.csv\n"
+"          Este debe definir las mismas propiedades que la clase (esto\n"
+"          incluye la existencia de una línea \"encabezado\" con los nombre\n"
+"          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\n"
+"        se encontraban definidos en el fichero importado, por lo tanto\n"
+"        reemplazarán todo contenido preexistente.\n"
+"\n"
+"        Los nuevos nodos son agregados a la base de datos existente - si\n"
+"        Ud. desea crear una base de datos nueva usando los datos\n"
+"        importados, entonces puede crear una nueva base de datos (o,\n"
+"        tediosamente, retirar toda los datos viejos.)\n"
+"        "
+
+#: ../roundup/admin.py:1235
+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\", y \"d\".\n"
+"        El 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:1263
+msgid "Invalid format"
+msgstr "Formato inválido"
+
+#: ../roundup/admin.py:1274
+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:1288
+#, python-format
+msgid "no such item \"%(designator)s\""
+msgstr "no existe un ítem llamado \"%(designator)s\""
+
+#: ../roundup/admin.py:1298
+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:1306
+#, python-format
+msgid "No such Role \"%(role)s\""
+msgstr "No existe un Rol llamado \"%(role)s\""
+
+#: ../roundup/admin.py:1312
+#, 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:1314
+#, 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:1317
+#, 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:1319
+#, 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:1322
+#, python-format
+msgid "Role \"%(name)s\":"
+msgstr "Rol \"%(name)s\":"
+
+#: ../roundup/admin.py:1327
+#, 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:1330
+#, python-format
+msgid " %(description)s (%(name)s for \"%(klass)s\" only)"
+msgstr " %(description)s (%(name)s para \"%(klass)s\" solamente)"
+
+#: ../roundup/admin.py:1333
+#, python-format
+msgid " %(description)s (%(name)s)"
+msgstr " %(description)s (%(name)s)"
+
+#: ../roundup/admin.py:1362
+#, 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:1368
+#, python-format
+msgid "Multiple commands match \"%(command)s\": %(list)s"
+msgstr "Coinciden mas de un comando \"%(command)s\": %(list)s"
+
+#: ../roundup/admin.py:1375
+msgid "Enter tracker home: "
+msgstr "Ingrese directorio base del tracker: "
+
+# ../roundup/admin.py:1296 :1302 :1322
+#: ../roundup/admin.py:1382 ../roundup/admin.py:1388 ../roundup/admin.py:1408
+#: ../roundup/admin.py:1382:1388:1408
+#, python-format
+msgid "Error: %(message)s"
+msgstr "Error: %(message)s"
+
+#: ../roundup/admin.py:1396
+#, python-format
+msgid "Error: Couldn't open tracker: %(message)s"
+msgstr "Error: No se pudo abrir el tracker: %(message)s"
+
+#: ../roundup/admin.py:1421
+#, 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:1426
+msgid "Note: command history and editing not available"
+msgstr "Nota: historia y edición de comandos no disponible"
+
+#: ../roundup/admin.py:1430
+msgid "roundup> "
+msgstr "roundup> "
+
+#: ../roundup/admin.py:1432
+msgid "exit..."
+msgstr "salir..."
+
+#: ../roundup/admin.py:1442
+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:2004
+#, python-format
+msgid "WARNING: invalid date tuple %r"
+msgstr "ATENCIÓN: tuple de fecha inválido %r"
+
+#: ../roundup/backends/rdbms_common.py:1445
+msgid "create"
+msgstr "crea"
+
+#: ../roundup/backends/rdbms_common.py:1611
+msgid "unlink"
+msgstr "desenlaza"
+
+#: ../roundup/backends/rdbms_common.py:1615
+msgid "link"
+msgstr "enlaza"
+
+#: ../roundup/backends/rdbms_common.py:1737
+msgid "set"
+msgstr "asigna"
+
+#: ../roundup/backends/rdbms_common.py:1761
+msgid "retired"
+msgstr "retira"
+
+#: ../roundup/backends/rdbms_common.py:1791
+msgid "restored"
+msgstr "restaura"
+
+#: ../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:169 ../roundup/cgi/actions.py:197
+#: ../roundup/cgi/actions.py:169:197
+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:175 ../roundup/cgi/actions.py:204
+#: ../roundup/cgi/actions.py:175:204
+msgid "You do not have permission to store queries"
+msgstr "Ud. no posee los permisos necesarios para grabar consultas"
+
+#: ../roundup/cgi/actions.py:310
+#, 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:357
+msgid "Items edited OK"
+msgstr "Items editados exitosamente"
+
+#: ../roundup/cgi/actions.py:416
+#, python-format
+msgid "%(class)s %(id)s %(properties)s edited ok"
+msgstr "Edición exitosa de %(properties)s de %(class)s %(id)s"
+
+#: ../roundup/cgi/actions.py:419
+#, python-format
+msgid "%(class)s %(id)s - nothing changed"
+msgstr "%(class)s %(id)s - sin modificaciones"
+
+#: ../roundup/cgi/actions.py:431
+#, python-format
+msgid "%(class)s %(id)s created"
+msgstr "%(class)s %(id)s creado"
+
+#: ../roundup/cgi/actions.py:463
+#, 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:475
+#, 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:499
+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:549
+#, 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:577
+#, python-format
+msgid "Edit Error: %s"
+msgstr "Error de edición: %s"
+
+# ../roundup/cgi/actions.py:579 :590 :761 :780
+#: ../roundup/cgi/actions.py:608 ../roundup/cgi/actions.py:619
+#: ../roundup/cgi/actions.py:790 ../roundup/cgi/actions.py:809
+#: ../roundup/cgi/actions.py:608:619 :790:809
+#, python-format
+msgid "Error: %s"
+msgstr "Error: %s"
+
+#: ../roundup/cgi/actions.py:645
+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:687
+#, 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:696
+msgid "Unknown username"
+msgstr "Usuario desconocido"
+
+#: ../roundup/cgi/actions.py:704
+msgid "Unknown email address"
+msgstr "Dirección de e-mail desconocida"
+
+#: ../roundup/cgi/actions.py:709
+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:734
+#, python-format
+msgid "Email sent to %s"
+msgstr "Se ha enviado un mensaje de e-mail a %s"
+
+#: ../roundup/cgi/actions.py:753
+msgid "You are now registered, welcome!"
+msgstr "Ud. se ha registrado exitosamente, bienvenido!"
+
+#: ../roundup/cgi/actions.py:798
+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:890
+msgid "You are logged out"
+msgstr "Ha salido del sistema exitosamente"
+
+#: ../roundup/cgi/actions.py:907
+msgid "Username required"
+msgstr "Se requiere el ingreso de un nombre de usuario"
+
+# ../roundup/cgi/actions.py:891 :895
+#: ../roundup/cgi/actions.py:942 ../roundup/cgi/actions.py:946
+#: ../roundup/cgi/actions.py:942:946
+msgid "Invalid login"
+msgstr "nombre de usuario ó contraseña inválidos"
+
+#: ../roundup/cgi/actions.py:952
+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 "<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>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 "<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>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
+#: ../roundup/cgi/cgitb.py:172:178
+msgid "<em>undefined</em>"
+msgstr "<em>indefinido/a</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>Ha ocurrido un error</title></head>\n"
+"<body><h1>Ha ocurrido un error</h1>\n"
+"<p>Se ha encontrado un problema procesando su requerimiento.\n"
+"Los administradores del tracker han sido notificados acerca del problema.</"
+"p>\n"
+"</body></html>"
+
+#: ../roundup/cgi/client.py:339
+msgid "Form Error: "
+msgstr "Error de formulario"
+
+#: ../roundup/cgi/client.py:394
+#, python-format
+msgid "Unrecognized charset: %r"
+msgstr "Conjunto de caracteres desconocido: %r"
+
+#: ../roundup/cgi/client.py:522
+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:677
+msgid "You are not allowed to view this file."
+msgstr "Ud. no tiene permitido ver este fichero"
+
+#: ../roundup/cgi/client.py:770
+#, python-format
+msgid "%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n"
+msgstr "%(starttag)sTiempo transcurrido: %(seconds)fs%(endtag)s\n"
+
+#: ../roundup/cgi/client.py:774
+#, 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 \"%(entry)s\" not a designator"
+msgstr "el enlace \"%(key)s\" valor \"%(entry)s\" no es un designador"
+
+#: ../roundup/cgi/form_parser.py:301
+#, 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:313
+#, python-format
+msgid ""
+"The form action claims to require property \"%(property)s\" which doesn't "
+"exist"
+msgstr ""
+"La accion de formulario especifica que requiere la propiedad "
+"\"%(property)s\" la cual no existe"
+
+#: ../roundup/cgi/form_parser.py:335
+#, 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:354 ../roundup/cgi/form_parser.py:380
+#: ../roundup/cgi/form_parser.py:354:380
+#, 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:377 ../roundup/cgi/form_parser.py:383
+#: ../roundup/cgi/form_parser.py:377:383
+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:418
+#, 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:551
+#, 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:574
+msgid "File is empty"
+msgstr "El fichero está vacío"
+
+#: ../roundup/cgi/templating.py:77
+#, 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:657
+msgid "(list)"
+msgstr "(lista)"
+
+#: ../roundup/cgi/templating.py:726
+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:740 ../roundup/cgi/templating.py:873
+#: ../roundup/cgi/templating.py:1294 ../roundup/cgi/templating.py:1323
+#: ../roundup/cgi/templating.py:1343 ../roundup/cgi/templating.py:1356
+#: ../roundup/cgi/templating.py:1407 ../roundup/cgi/templating.py:1430
+#: ../roundup/cgi/templating.py:1466 ../roundup/cgi/templating.py:1503
+#: ../roundup/cgi/templating.py:1556 ../roundup/cgi/templating.py:1573
+#: ../roundup/cgi/templating.py:1657 ../roundup/cgi/templating.py:1677
+#: ../roundup/cgi/templating.py:1695 ../roundup/cgi/templating.py:1727
+#: ../roundup/cgi/templating.py:1737 ../roundup/cgi/templating.py:1789
+#: ../roundup/cgi/templating.py:1978 ../roundup/cgi/templating.py:740:873
+#: :1294:1323 :1343:1356 :1407:1430 :1466:1503 :1556:1573 :1657:1677
+#: :1695:1727 :1737:1789:1978
+msgid "[hidden]"
+msgstr "[oculto]"
+
+#: ../roundup/cgi/templating.py:741
+msgid "New node - no history"
+msgstr "Nuevo nodo - sin historia"
+
+#: ../roundup/cgi/templating.py:855
+msgid "Submit Changes"
+msgstr "Enviar modificaciones"
+
+#: ../roundup/cgi/templating.py:937
+msgid "<em>The indicated property no longer exists</em>"
+msgstr "<em>La propiedad indicada ya no existe</em>"
+
+#: ../roundup/cgi/templating.py:938
+#, python-format
+msgid "<em>%s: %s</em>\n"
+msgstr "<em>%s: %s</em>\n"
+
+#: ../roundup/cgi/templating.py:951
+#, 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:984 ../roundup/cgi/templating.py:1008
+#: ../roundup/cgi/templating.py:984:1008
+msgid "<strike>The linked node no longer exists</strike>"
+msgstr "<strike>El nodo relacionado ya no existe</strike>"
+
+#: ../roundup/cgi/templating.py:1061
+#, python-format
+msgid "%s: (no value)"
+msgstr "%s: (sin valor)"
+
+#: ../roundup/cgi/templating.py:1073
+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:1085
+msgid "<tr><td colspan=4><strong>Note:</strong></td></tr>"
+msgstr "<tr><td colspan=4><strong>Nota:</strong></td></tr>"
+
+#: ../roundup/cgi/templating.py:1094
+msgid "History"
+msgstr "Historia"
+
+#: ../roundup/cgi/templating.py:1096
+msgid "<th>Date</th>"
+msgstr "<th>Fecha</th>"
+
+#: ../roundup/cgi/templating.py:1097
+msgid "<th>User</th>"
+msgstr "<th>Usuario</th>"
+
+#: ../roundup/cgi/templating.py:1098
+msgid "<th>Action</th>"
+msgstr "<th>Acción</th>"
+
+#: ../roundup/cgi/templating.py:1099
+msgid "<th>Args</th>"
+msgstr "<th>Args</th>"
+
+#: ../roundup/cgi/templating.py:1141
+#, python-format
+msgid "Copy of %(class)s %(id)s"
+msgstr "Copia de %(class)s %(id)s"
+
+#: ../roundup/cgi/templating.py:1434
+msgid "*encrypted*"
+msgstr "*cifrado*"
+
+#: ../roundup/cgi/templating.py:1507 ../roundup/cgi/templating.py:1528
+#: ../roundup/cgi/templating.py:1534 ../roundup/cgi/templating.py:1050:1507
+#: :1528:1534
+msgid "No"
+msgstr "No"
+
+#: ../roundup/cgi/templating.py:1507 ../roundup/cgi/templating.py:1526
+#: ../roundup/cgi/templating.py:1531 ../roundup/cgi/templating.py:1050:1507
+#: :1526:1531
+msgid "Yes"
+msgstr "Si"
+
+#: ../roundup/cgi/templating.py:1620
+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:1780
+#, 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:1853
+#, python-format
+msgid "<option %svalue=\"-1\">- no selection -</option>"
+msgstr "<option %svalue=\"-1\">- sin selección -</option>"
+
+#: ../roundup/date.py:300
+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:359
+#, 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:666
+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:685
+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:822
+#, 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:826
+#, python-format
+msgid "%(number)s month"
+msgid_plural "%(number)s months"
+msgstr[0] "%(number)s mes"
+msgstr[1] "%(number)s meses"
+
+#: ../roundup/date.py:830
+#, python-format
+msgid "%(number)s week"
+msgid_plural "%(number)s weeks"
+msgstr[0] "%(number)s semana"
+msgstr[1] "%(number)s semanas"
+
+#: ../roundup/date.py:834
+#, 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:838
+msgid "tomorrow"
+msgstr "mañana"
+
+#: ../roundup/date.py:840
+msgid "yesterday"
+msgstr "ayer"
+
+#: ../roundup/date.py:843
+#, python-format
+msgid "%(number)s hour"
+msgid_plural "%(number)s hours"
+msgstr[0] "%(number)s hora"
+msgstr[1] "%(number)s horas"
+
+#: ../roundup/date.py:847
+msgid "an hour"
+msgstr "una hora"
+
+#: ../roundup/date.py:849
+msgid "1 1/2 hours"
+msgstr "1 hora y 1/2"
+
+#: ../roundup/date.py:851
+#, 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:855
+msgid "in a moment"
+msgstr "en un momento"
+
+#: ../roundup/date.py:857
+msgid "just now"
+msgstr "ahora"
+
+#: ../roundup/date.py:860
+msgid "1 minute"
+msgstr "1 minuto"
+
+#: ../roundup/date.py:863
+#, python-format
+msgid "%(number)s minute"
+msgid_plural "%(number)s minutes"
+msgstr[0] "%(number)s minuto"
+msgstr[1] "%(number)s minutos"
+
+#: ../roundup/date.py:866
+msgid "1/2 an hour"
+msgstr "media hora"
+
+#: ../roundup/date.py:868
+#, 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:872
+#, python-format
+msgid "%s ago"
+msgstr "hace %s"
+
+#: ../roundup/date.py:874
+#, python-format
+msgid "in %s"
+msgstr "en %s"
+
+#: ../roundup/init.py:134
+#, 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/mailgw.py:584
+msgid ""
+"\n"
+"Emails to Roundup trackers must include a Subject: line!\n"
+msgstr ""
+"\n"
+"Todos los e-mails enviados a trackers Roundup deben incluir un Asunto:!\n"
+
+#: ../roundup/mailgw.py:708
+#, python-format
+msgid ""
+"\n"
+"The message you sent to roundup did not contain a properly formed subject\n"
+"line. The subject must contain a class name or designator to indicate the\n"
+"'topic' of the message. For example:\n"
+"    Subject: [issue] This is a new issue\n"
+"      - this will create a new issue in the tracker with the title 'This is\n"
+"        a new issue'.\n"
+"    Subject: [issue1234] This is a followup to issue 1234\n"
+"      - this will append the message's contents to the existing issue 1234\n"
+"        in the tracker.\n"
+"\n"
+"Subject was: '%(subject)s'\n"
+msgstr ""
+"\n"
+"El mensaje que envió a Roundup no contiene un asunto con el formato "
+"apropiado\n"
+"El asunto debe contener un nombre de clase o designador para indicar para\n"
+"indicar el 'tópico' del mensaje. Por ejemplo:\n"
+"    Asunto: [issue] Este es un nuevo issue\n"
+"      - Esto creará un nuevo issue en el tracker con el título 'Este es un\n"
+"        nuevo issue'.\n"
+"    Asunto: [issue1234] Esta es un agregado al issue 1234\n"
+"      - Esto anexará el contenido del e-mail al issue 1234 ya existente\n"
+"        en el tracker.\n"
+"\n"
+"El asunto que Ud. envió es: '%(subject)s'\n"
+
+#: ../roundup/mailgw.py:746
+#, python-format
+msgid ""
+"\n"
+"The class name you identified in the subject line (\"%(classname)s\") does\n"
+"not exist in the database.\n"
+"\n"
+"Valid class names are: %(validname)s\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"\n"
+"La clase que Ud. identificó en el Asunto (\"%(classname)s\") \n"
+"no existe en la base de datos.\n"
+"\n"
+"Nombres válidos de clases son: %(validname)s\n"
+"El asunto que Ud. envió es: \"%(subject)s\"\n"
+
+#: ../roundup/mailgw.py:754
+#, python-format
+msgid ""
+"\n"
+"You did not identify a class name in the subject line and there is no\n"
+"default set for this tracker. The subject must contain a class name or\n"
+"designator to indicate the 'topic' of the message. For example:\n"
+"    Subject: [issue] This is a new issue\n"
+"      - this will create a new issue in the tracker with the title 'This is\n"
+"        a new issue'.\n"
+"    Subject: [issue1234] This is a followup to issue 1234\n"
+"      - this will append the message's contents to the existing issue 1234\n"
+"        in the tracker.\n"
+"\n"
+"Subject was: '%(subject)s'\n"
+msgstr ""
+"\n"
+"Ud. no indicó un nombre de clase en Asunto y el tracker no tiene\n"
+"configurado un valor por omisión. El asunto debe contener un nombre\n"
+"de clase o designador para indicar para indicar el 'tópico' del mensaje.\n"
+"Por ejemplo:\n"
+"    Asunto: [issue] Este es un nuevo issue\n"
+"      - Esto creará un nuevo issue en el tracker con el título 'Este es un\n"
+"        nuevo issue'.\n"
+"    Asunto: [issue1234] Esta es un agregado al issue 1234\n"
+"      - Esto anexará el contenido del e-mail al issue 1234 ya existente\n"
+"        en el tracker.\n"
+"\n"
+"El asunto que Ud. envió es: '%(subject)s'\n"
+
+#: ../roundup/mailgw.py:795
+#, python-format
+msgid ""
+"\n"
+"I cannot match your message to a node in the database - you need to either\n"
+"supply a full designator (with number, eg \"[issue123]\") or keep the\n"
+"previous subject title intact so I can match that.\n"
+"\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"\n"
+"No puedo encontrar un nodo en la base de datos que coincida con el mensaje\n"
+"que Ud. ha enviado - Necesita proveer un designador completo (con número,\n"
+"por ejemplo \"[issue123]\" o mantener intacto el Asunto previo de manera\n"
+"que yo pueda encontrar una coincidencia.\n"
+"\n"
+"El asunto que Ud. envió es: \"%(subject)s\"\n"
+
+#: ../roundup/mailgw.py:828
+#, python-format
+msgid ""
+"\n"
+"The node specified by the designator in the subject of your message\n"
+"(\"%(nodeid)s\") does not exist.\n"
+"\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"\n"
+"El nodo especificado por el designador en el Asunto de su mensaje\n"
+"(\"%(nodeid)s\") no existe.\n"
+"\n"
+"El asunto que Ud. envió es: \"%(subject)s\"\n"
+
+#: ../roundup/mailgw.py:856
+#, python-format
+msgid ""
+"\n"
+"The mail gateway is not properly set up. Please contact\n"
+"%(mailadmin)s and have them fix the incorrect class specified as:\n"
+"  %(current_class)s\n"
+msgstr ""
+"\n"
+"La pasarela de e-mail no está correctamente configurada. Por favor póngase\n"
+"en contacto con %(mailadmin)s y pídales que solucionen la siguiente clase "
+"incorrecta:\n"
+"  %(current_class)s\n"
+
+#: ../roundup/mailgw.py:879
+#, python-format
+msgid ""
+"\n"
+"The mail gateway is not properly set up. Please contact\n"
+"%(mailadmin)s and have them fix the incorrect properties:\n"
+"  %(errors)s\n"
+msgstr ""
+"\n"
+"La pasarela de e-mail no está correctamente configurada. Por favor póngase\n"
+"en contacto con %(mailadmin)s y pídales que solucionen las propiedades "
+"incorrectas:\n"
+"  %(errors)s\n"
+
+#: ../roundup/mailgw.py:919
+#, python-format
+msgid ""
+"\n"
+"You are not a registered user.%(registration_info)s\n"
+"\n"
+"Unknown address: %(from_address)s\n"
+msgstr ""
+"\n"
+"Ud. no es un usuario registrado.%(registration_info)s\n"
+"\n"
+"Dirección desconocida: %(from_address)s\n"
+
+#: ../roundup/mailgw.py:927
+msgid "You are not permitted to access this tracker."
+msgstr "Ud. no posee los permisos necesarios para acceder a este tracker."
+
+#: ../roundup/mailgw.py:934
+#, python-format
+msgid "You are not permitted to edit %(classname)s."
+msgstr "Ud. no tiene permitido editar %(classname)s."
+
+#: ../roundup/mailgw.py:938
+#, python-format
+msgid "You are not permitted to create %(classname)s."
+msgstr "Ud. no tiene permitido crear %(classname)s."
+
+#: ../roundup/mailgw.py:985
+#, python-format
+msgid ""
+"\n"
+"There were problems handling your subject line argument list:\n"
+"- %(errors)s\n"
+"\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"\n"
+"Han habido problemas procesando la lista de argumentos del Asunto de su "
+"mensaje:\n"
+"- %(errors)s\n"
+"\n"
+"El Asunto que Ud. envió es: \"%(subject)s\"\n"
+
+#: ../roundup/mailgw.py:1013
+msgid ""
+"\n"
+"Roundup requires the submission to be plain text. The message parser could\n"
+"not find a text/plain part to use.\n"
+msgstr ""
+"\n"
+"Roundup requiere que el envío sea en texto plano. El parser de mensajes no "
+"ha\n"
+"podido localizar una parte MIME text/plain en su mensaje que pueda ser "
+"usada.\n"
+
+#: ../roundup/mailgw.py:1030
+msgid "You are not permitted to create files."
+msgstr "Ud. no tiene permitida la creación de ficheros."
+
+#: ../roundup/mailgw.py:1044
+#, python-format
+msgid "You are not permitted to add files to %(classname)s."
+msgstr "Ud. no tiene permitido agregar ficheros a %(classname)s."
+
+#: ../roundup/mailgw.py:1062
+msgid "You are not permitted to create messages."
+msgstr "Ud. no tiene permitido crear mensajes."
+
+#: ../roundup/mailgw.py:1070
+#, python-format
+msgid ""
+"\n"
+"Mail message was rejected by a detector.\n"
+"%(error)s\n"
+msgstr ""
+"\n"
+"El mensaje de e-mail ha sido rechazado por un detector.\n"
+"%(error)s\n"
+
+#: ../roundup/mailgw.py:1078
+#, python-format
+msgid "You are not permitted to add messages to %(classname)s."
+msgstr "Ud. no tiene permitido agregar mensajes a %(classname)s."
+
+#: ../roundup/mailgw.py:1105
+#, python-format
+msgid "You are not permitted to edit property %(prop)s of class %(classname)s."
+msgstr ""
+"Ud. no tiene permitido editar la propiedad %(prop)s de la clase %(classname)"
+"s."
+
+#: ../roundup/mailgw.py:1113
+#, python-format
+msgid ""
+"\n"
+"There was a problem with the message you sent:\n"
+"   %(message)s\n"
+msgstr ""
+"\n"
+"Ha habido un problema con el mensaje que envíó:\n"
+"   %(message)s\n"
+
+#: ../roundup/mailgw.py:1135
+msgid "not of form [arg=value,value,...;arg=value,value,...]"
+msgstr "no es de la forma [arg=valor,valor,...;arg=valor,valor,...]"
+
+#: ../roundup/roundupdb.py:147
+msgid "files"
+msgstr "ficheros"
+
+#: ../roundup/roundupdb.py:147
+msgid "messages"
+msgstr "mensajes"
+
+#: ../roundup/roundupdb.py:147
+msgid "nosy"
+msgstr "interesados"
+
+#: ../roundup/roundupdb.py:147
+msgid "superseder"
+msgstr "reemplazado por"
+
+#: ../roundup/roundupdb.py:147
+msgid "title"
+msgstr "título"
+
+#: ../roundup/roundupdb.py:148
+msgid "assignedto"
+msgstr "asignadoa"
+
+#: ../roundup/roundupdb.py:148
+msgid "keyword"
+msgstr "Palabra clave"
+
+#: ../roundup/roundupdb.py:148
+msgid "priority"
+msgstr "prioridad"
+
+#: ../roundup/roundupdb.py:148
+msgid "status"
+msgstr "estado"
+
+#: ../roundup/roundupdb.py:151
+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:151
+msgid "actor"
+msgstr "últimoactor"
+
+#: ../roundup/roundupdb.py:151
+msgid "creation"
+msgstr "creación"
+
+#: ../roundup/roundupdb.py:151
+msgid "creator"
+msgstr "creador"
+
+#: ../roundup/roundupdb.py:309
+#, python-format
+msgid "New submission from %(authname)s%(authaddr)s:"
+msgstr "Nuevo aporte de %(authname)s%(authaddr)s:"
+
+#: ../roundup/roundupdb.py:312
+#, python-format
+msgid "%(authname)s%(authaddr)s added the comment:"
+msgstr "%(authname)s%(authaddr)s agregó el comentario:"
+
+#: ../roundup/roundupdb.py:315
+#, python-format
+msgid "Change by %(authname)s%(authaddr)s:"
+msgstr "Modificación de %(authname)s%(authaddr)s:"
+
+#: ../roundup/roundupdb.py:342
+#, python-format
+msgid "File '%(filename)s' not attached - you can download it from %(link)s."
+msgstr "Fichero '%(filename)s' no anexado - puede descargarlo de %(link)s."
+
+#: ../roundup/roundupdb.py:615
+#, python-format
+msgid ""
+"\n"
+"Now:\n"
+"%(new)s\n"
+"Was:\n"
+"%(old)s"
+msgstr ""
+"\n"
+"Ahora:\n"
+"%(new)s\n"
+"Antes:\n"
+"%(old)s"
+
+#: ../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 que 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 class] [[-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@server\n"
+" The username and password may be omitted:\n"
+"    pop username@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"
+"POPS:\n"
+" Connect to a POP server over ssl. This requires python 2.4 or later.\n"
+" This supports the same notation as POP.\n"
+"\n"
+"APOP:\n"
+" Same as POP, but using Authenticated POP:\n"
+"    apop username:password@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@server\n"
+" It also allows you to specify a specific mailbox other than INBOX using\n"
+" this format:\n"
+"    imap username:password@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@server [mailbox]\n"
+"\n"
+msgstr ""
+"Uso: %(program)s [-v] [-c clase] [[-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\n"
+"     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 fichero 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 roundup-mailgw.\n"
+"La clase por omisión es msg, pero las otras clases: issue, file, user\n"
+"tambien pueden usarse. Las opciones -S y --set usan la misma notación\n"
+"propiedad=valor[;propiedad=valor] aceptada por el comando roundup de\n"
+"línea de comandos o los comandos que pueden ser pasados en el campo\n"
+"Asunto: de un mensaje de correo electrónico.\n"
+"\n"
+"También le permite establecer el tipo de mensaje basado en la dirección de\n"
+"correo usada.\n"
+"\n"
+"PIPE:\n"
+" En el primer caso, la pasarela de correo lee un mensaje desde la entrada\n"
+" estándar 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 fichero de\n"
+" spool de correo y envía los mismos de a uno al módulo roundup.mailgw. El\n"
+" fichero se vacía una vez que todos los mensajes han sido procesados\n"
+" exitosamente. El fichero 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@servidor\n"
+" El nombreusuario y la contraseña pueden omitirse por lo que:\n"
+"    pop nombreusuario@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@servidor\n"
+"\n"
+"IMAP:\n"
+" Se conecta a un servidor IMAP. Esta forma soporta la misma notación que\n"
+" correo POP\n"
+"    imap nombreusuario:contraseña@servidor\n"
+" También le permite especificar una carpeta distinta a INBOX usando el\n"
+" formato:\n"
+"    imap nombreusuario:contraseña@servidor carpeta\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@servidor [carpeta]\n"
+"\n"
+
+#: ../roundup/scripts/roundup_mailgw.py:151
+msgid "Error: not enough source specification information"
+msgstr "Error: no hay información de especificación de origen suficiente"
+
+#: ../roundup/scripts/roundup_mailgw.py:167
+msgid "Error: a later version of python is required"
+msgstr "Error: se require una versión mas reciente de python"
+
+#: ../roundup/scripts/roundup_mailgw.py:170
+msgid "Error: pop specification not valid"
+msgstr "Error: especification pop no válida"
+
+#: ../roundup/scripts/roundup_mailgw.py:177
+msgid "Error: apop specification not valid"
+msgstr "Error: especification apop no válida"
+
+#: ../roundup/scripts/roundup_mailgw.py:189
+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:76
+msgid "WARNING: generating temporary SSL certificate"
+msgstr "ATENCION: generando certificado SLL temporario"
+
+#: ../roundup/scripts/roundup_server.py:253
+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:389
+#, python-format
+msgid "Error: %s: %s"
+msgstr "Error: %s: %s"
+
+#: ../roundup/scripts/roundup_server.py:399
+msgid "WARNING: ignoring \"-g\" argument, not root"
+msgstr "ATENCIÓN: ignorando argumento \"-g\" , Ud. no es root"
+
+#: ../roundup/scripts/roundup_server.py:405
+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:414
+#, python-format
+msgid "Group %(group)s doesn't exist"
+msgstr "El grupo %(group)s no existe"
+
+#: ../roundup/scripts/roundup_server.py:425
+msgid "Can't run as root!"
+msgstr "No puede ejecutarse como root!"
+
+#: ../roundup/scripts/roundup_server.py:428
+msgid "WARNING: ignoring \"-u\" argument, not root"
+msgstr "ATENCIÓN: ignorando argumento \"-u\", Ud. no es root"
+
+#: ../roundup/scripts/roundup_server.py:434
+msgid "Can't change users - no pwd module"
+msgstr "No puedo cambiar usuarios - no existe el módulo pwd"
+
+#: ../roundup/scripts/roundup_server.py:443
+#, python-format
+msgid "User %(user)s doesn't exist"
+msgstr "El usuario %(user)s no existe"
+
+#: ../roundup/scripts/roundup_server.py:592
+#, 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:620
+#, 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:688
+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 fichero 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 fichero de registro.\n"
+"               Tipee \"roundup-server -c help\" para ver ayuda específica "
+"para\n"
+"               Servicios Web."
+
+#: ../roundup/scripts/roundup_server.py:695
+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 fichero especificado por PIDfile.\n"
+"               La opción -l *debe* ser especificada si se usa la opción -d."
+
+#: ../roundup/scripts/roundup_server.py:702
+#, 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"
+" -i <fname>    set tracker index template\n"
+" -s            enable SSL\n"
+" -e <fname>    PEM file containing SSL key and certificate\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 fichero de configuración y sale\n"
+" -C <fname>    usa el fichero 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 fichero 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"
+" -i <fname>    especifica la plantilla del índice del tracker\n"
+" -s            activa SSL\n"
+" -e <fname>    fichero PEM que contiene la llave y el certificado SSL\n"
+" -t <mode>     modo 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 fichero de configuración y sale\n"
+" --config <fname>   usa el fichero de configuración <fname>\n"
+" Todos las variables de la sección [main] del fichero 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 fichero de configuración:\n"
+"   El fichero de configuración del Servidor Roundup tiene un formato de "
+"fichero.ini común.\n"
+"   El fichero de configuración creado con 'roundup-server -S' contiene\n"
+"   explicaciones detalladas para cada opción. Por favor vea dicho fichero "
+"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:860
+msgid "Instances must be name=home"
+msgstr "Las Instancias debe ser de la forma nombre=directorio base"
+
+#: ../roundup/scripts/roundup_server.py:874
+#, python-format
+msgid "Configuration saved to %s"
+msgstr "Configuración guardada en %s"
+
+#: ../roundup/scripts/roundup_server.py:892
+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:907
+#, 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-empty.html:6
+msgid "Please specify your search parameters!"
+msgstr "¡Por favor especifique sus parámetros de búsqueda!"
+
+#: ../templates/classic/html/_generic.help-list.html:20
+#: ../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:35
+#: ../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:35
+#: ../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.help-list.html:34
+msgid "1..25 out of 50"
+msgstr "1..25 de 50"
+
+#: ../templates/classic/html/_generic.help-search.html:9
+msgid ""
+"Generic template ${template} or version for class ${classname} is not yet "
+"implemented"
+msgstr ""
+"Aun no están implementadas una plantilla genérica ${template} o una "
+"version para la clase ${classname}"
+
+#: ../templates/classic/html/_generic.help-submit.html:57
+#: ../templates/classic/html/_generic.help.html:31
+#: ../templates/minimal/html/_generic.help.html:31
+msgid " Cancel "
+msgstr " Cancelar "
+
+#: ../templates/classic/html/_generic.help-submit.html:63
+#: ../templates/classic/html/_generic.help.html:34
+#: ../templates/minimal/html/_generic.help.html:34
+msgid " Apply "
+msgstr " Aplicar "
+
+#: ../templates/classic/html/_generic.help.html:9
+#: ../templates/classic/html/user.help.html:13
+#: ../templates/minimal/html/_generic.help.html:9
+msgid "${property} help - ${tracker}"
+msgstr "${property} ayuda - ${tracker}"
+
+#: ../templates/classic/html/_generic.help.html:41
+#: ../templates/classic/html/help.html:21
+#: ../templates/classic/html/issue.index.html:80
+#: ../templates/minimal/html/_generic.help.html:41
+msgid "&lt;&lt; previous"
+msgstr "&lt;&lt; anterior"
+
+#: ../templates/classic/html/_generic.help.html:53
+#: ../templates/classic/html/help.html:28
+#: ../templates/classic/html/issue.index.html:88
+#: ../templates/minimal/html/_generic.help.html:53
+msgid "${start}..${end} out of ${total}"
+msgstr "${start}..${end} de un total de ${total}"
+
+#: ../templates/classic/html/_generic.help.html:57
+#: ../templates/classic/html/help.html:32
+#: ../templates/classic/html/issue.index.html:91
+#: ../templates/minimal/html/_generic.help.html:57
+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:19
+#: ../templates/classic/html/_generic.item.html:16
+#: ../templates/classic/html/file.item.html:13
+#: ../templates/classic/html/issue.index.html:20
+#: ../templates/classic/html/issue.item.html:32
+#: ../templates/classic/html/msg.item.html:30
+#: ../templates/classic/html/user.index.html:13
+#: ../templates/classic/html/user.item.html:39
+#: ../templates/minimal/html/_generic.index.html:19
+#: ../templates/minimal/html/_generic.item.html:17
+#: ../templates/minimal/html/user.index.html:13
+#: ../templates/minimal/html/user.item.html:39
+#: ../templates/minimal/html/user.register.html:17
+msgid "Please login with your username and password."
+msgstr "Por favor identifíquese con su mombre de usuario y contraseña."
+
+#: ../templates/classic/html/_generic.index.html:28
+#: ../templates/minimal/html/_generic.index.html:28
+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:50
+#: ../templates/minimal/html/_generic.index.html:50
+msgid "Edit Items"
+msgstr "Editar Items"
+
+#: ../templates/classic/html/file.index.html:4
+msgid "List of files - ${tracker}"
+msgstr "Lista de ficheros - ${tracker}"
+
+#: ../templates/classic/html/file.index.html:5
+msgid "List of files"
+msgstr "Lista de ficheros"
+
+#: ../templates/classic/html/file.index.html:10
+msgid "Download"
+msgstr "Descargar"
+
+#: ../templates/classic/html/file.index.html:11
+#: ../templates/classic/html/file.item.html:27
+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:48
+msgid "Date"
+msgstr "Fecha"
+
+#: ../templates/classic/html/file.item.html:2
+msgid "File display - ${tracker}"
+msgstr "Visualización de ficheros - ${tracker}"
+
+#: ../templates/classic/html/file.item.html:4
+msgid "File display"
+msgstr "Visualización de ficheros"
+
+#: ../templates/classic/html/file.item.html:23
+#: ../templates/classic/html/user.register.html:17
+msgid "Name"
+msgstr "Nombre"
+
+#: ../templates/classic/html/file.item.html:45
+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
+#: ../templates/classic/html/issue.index.html:10
+msgid "List of issues"
+msgstr "Lista de issues"
+
+#: ../templates/classic/html/issue.index.html:27
+#: ../templates/classic/html/issue.item.html:49
+msgid "Priority"
+msgstr "Prioridad"
+
+#: ../templates/classic/html/issue.index.html:28
+msgid "ID"
+msgstr "ID"
+
+#: ../templates/classic/html/issue.index.html:29
+msgid "Creation"
+msgstr "Creación"
+
+#: ../templates/classic/html/issue.index.html:30
+msgid "Activity"
+msgstr "Actividad"
+
+#: ../templates/classic/html/issue.index.html:31
+msgid "Actor"
+msgstr "último actor"
+
+#: ../templates/classic/html/issue.index.html:32
+#: ../templates/classic/html/keyword.item.html:37
+msgid "Keyword"
+msgstr "Palabra clave"
+
+#: ../templates/classic/html/issue.index.html:33
+#: ../templates/classic/html/issue.item.html:44
+msgid "Title"
+msgstr "Título"
+
+#: ../templates/classic/html/issue.index.html:34
+#: ../templates/classic/html/issue.item.html:51
+msgid "Status"
+msgstr "Estado"
+
+#: ../templates/classic/html/issue.index.html:35
+msgid "Creator"
+msgstr "Creador"
+
+#: ../templates/classic/html/issue.index.html:36
+msgid "Assigned&nbsp;To"
+msgstr "Asignado&nbsp;a"
+
+#: ../templates/classic/html/issue.index.html:104
+msgid "Download as CSV"
+msgstr "Descargar como CSV"
+
+#: ../templates/classic/html/issue.index.html:114
+msgid "Sort on:"
+msgstr "Ordenar por:"
+
+#: ../templates/classic/html/issue.index.html:118
+#: ../templates/classic/html/issue.index.html:139
+msgid "- nothing -"
+msgstr "- nada -"
+
+#: ../templates/classic/html/issue.index.html:126
+#: ../templates/classic/html/issue.index.html:147
+msgid "Descending:"
+msgstr "Descendente:"
+
+#: ../templates/classic/html/issue.index.html:135
+msgid "Group on:"
+msgstr "Agrupar por:"
+
+#: ../templates/classic/html/issue.index.html:154
+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
+msgid "New Issue - ${tracker}"
+msgstr "Nuevo Issue - ${tracker}"
+
+#: ../templates/classic/html/issue.item.html:14
+msgid "New Issue"
+msgstr "Nuevo Issue"
+
+#: ../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 "Issue${id}"
+
+#: ../templates/classic/html/issue.item.html:22
+msgid "Issue${id} Editing"
+msgstr "Edición de Issue${id}"
+
+#: ../templates/classic/html/issue.item.html:56
+msgid "Superseder"
+msgstr "Reemplazado por"
+
+#: ../templates/classic/html/issue.item.html:61
+msgid "View:"
+msgstr "Ver:"
+
+#: ../templates/classic/html/issue.item.html:67
+msgid "Nosy List"
+msgstr "Lista de interesados"
+
+#: ../templates/classic/html/issue.item.html:76
+msgid "Assigned To"
+msgstr "Asignado a"
+
+#: ../templates/classic/html/issue.item.html:78
+#: ../templates/classic/html/page.html:103
+#: ../templates/minimal/html/page.html:102
+msgid "Keywords"
+msgstr "Palabras clave"
+
+#: ../templates/classic/html/issue.item.html:86
+msgid "Change Note"
+msgstr "Nota de modificación"
+
+#: ../templates/classic/html/issue.item.html:94
+msgid "File"
+msgstr "Fichero"
+
+#: ../templates/classic/html/issue.item.html:106
+msgid "Make a copy"
+msgstr "Hacer una copia"
+
+#: ../templates/classic/html/issue.item.html:114
+#: ../templates/classic/html/user.item.html:153
+#: ../templates/classic/html/user.register.html:69
+#: ../templates/minimal/html/user.item.html:153
+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:128
+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:132
+#: ../templates/classic/html/msg.item.html:61
+msgid "Files"
+msgstr "Ficheros"
+
+#: ../templates/classic/html/issue.item.html:134
+#: ../templates/classic/html/msg.item.html:63
+msgid "File name"
+msgstr "Nombre de fichero"
+
+#: ../templates/classic/html/issue.item.html:135
+#: ../templates/classic/html/msg.item.html:64
+msgid "Uploaded"
+msgstr "Subido"
+
+#: ../templates/classic/html/issue.item.html:136
+msgid "Type"
+msgstr "Tipo"
+
+#: ../templates/classic/html/issue.item.html:137
+#: ../templates/classic/html/query.edit.html:30
+msgid "Edit"
+msgstr "Editar"
+
+#: ../templates/classic/html/issue.item.html:138
+msgid "Remove"
+msgstr "Eliminar"
+
+#: ../templates/classic/html/issue.item.html:158
+#: ../templates/classic/html/issue.item.html:179
+#: ../templates/classic/html/query.edit.html:50
+msgid "remove"
+msgstr "eliminar"
+
+#: ../templates/classic/html/issue.item.html:165
+#: ../templates/classic/html/msg.index.html:9
+msgid "Messages"
+msgstr "Mensajes"
+
+#: ../templates/classic/html/issue.item.html:169
+msgid "msg${id} (view)"
+msgstr "mensaje${id} (ver)"
+
+#: ../templates/classic/html/issue.item.html:170
+msgid "Author: ${author}"
+msgstr "Autor: ${author}"
+
+#: ../templates/classic/html/issue.item.html:172
+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:31
+msgid "Filter on"
+msgstr "Filtrar por"
+
+#: ../templates/classic/html/issue.search.html:32
+msgid "Display"
+msgstr "Visualizar"
+
+#: ../templates/classic/html/issue.search.html:33
+msgid "Sort on"
+msgstr "Ordenar por"
+
+#: ../templates/classic/html/issue.search.html:34
+msgid "Group on"
+msgstr "Agrupar por"
+
+#: ../templates/classic/html/issue.search.html:38
+msgid "All text*:"
+msgstr "Todo el texto*:"
+
+#: ../templates/classic/html/issue.search.html:46
+msgid "Title:"
+msgstr "Título:"
+
+#: ../templates/classic/html/issue.search.html:56
+msgid "Keyword:"
+msgstr "Palabra clave:"
+
+#: ../templates/classic/html/issue.search.html:58
+#: ../templates/classic/html/issue.search.html:123
+#: ../templates/classic/html/issue.search.html:139
+msgid "not selected"
+msgstr "no seleccionado"
+
+#: ../templates/classic/html/issue.search.html:67
+msgid "ID:"
+msgstr "ID:"
+
+#: ../templates/classic/html/issue.search.html:75
+msgid "Creation Date:"
+msgstr "Fecha de creación:"
+
+#: ../templates/classic/html/issue.search.html:86
+msgid "Creator:"
+msgstr "Creador:"
+
+#: ../templates/classic/html/issue.search.html:88
+msgid "created by me"
+msgstr "creado por mí"
+
+#: ../templates/classic/html/issue.search.html:97
+msgid "Activity:"
+msgstr "Actividad:"
+
+#: ../templates/classic/html/issue.search.html:108
+msgid "Actor:"
+msgstr "Último actor:"
+
+#: ../templates/classic/html/issue.search.html:110
+msgid "done by me"
+msgstr "hecho por mí"
+
+#: ../templates/classic/html/issue.search.html:121
+msgid "Priority:"
+msgstr "Prioridad:"
+
+#: ../templates/classic/html/issue.search.html:134
+msgid "Status:"
+msgstr "Estado:"
+
+#: ../templates/classic/html/issue.search.html:137
+msgid "not resolved"
+msgstr "sin resolver"
+
+#: ../templates/classic/html/issue.search.html:152
+msgid "Assigned to:"
+msgstr "Asignado a:"
+
+#: ../templates/classic/html/issue.search.html:155
+msgid "assigned to me"
+msgstr "asignado a mí"
+
+#: ../templates/classic/html/issue.search.html:157
+msgid "unassigned"
+msgstr "no asignado"
+
+#: ../templates/classic/html/issue.search.html:167
+msgid "No Sort or group:"
+msgstr "No ordenar o agrupar"
+
+#: ../templates/classic/html/issue.search.html:175
+msgid "Pagesize:"
+msgstr "Tamaño de página"
+
+#: ../templates/classic/html/issue.search.html:181
+msgid "Start With:"
+msgstr "Comenzar con:"
+
+#: ../templates/classic/html/issue.search.html:187
+msgid "Sort Descending:"
+msgstr "Ordenar en forma descendente:"
+
+#: ../templates/classic/html/issue.search.html:194
+msgid "Group Descending:"
+msgstr "Agrupar en forma descendente:"
+
+#: ../templates/classic/html/issue.search.html:201
+msgid "Query name**:"
+msgstr "Nombre de la consulta**:"
+
+#: ../templates/classic/html/issue.search.html:213
+#: ../templates/classic/html/page.html:43
+#: ../templates/classic/html/page.html:92
+#: ../templates/classic/html/user.help-search.html:69
+#: ../templates/minimal/html/page.html:43
+#: ../templates/minimal/html/page.html:91
+msgid "Search"
+msgstr "Buscar"
+
+#: ../templates/classic/html/issue.search.html:218
+msgid "*: The \"all text\" field will look in message bodies and issue titles"
+msgstr ""
+"*: El campo \"Todo el texto\" busca en los cuerpos de los mensajes y los "
+"títulos de los issues"
+
+#: ../templates/classic/html/issue.search.html:221
+msgid ""
+"**: If you supply a name, the query will be saved off and available as a "
+"link in the sidebar"
+msgstr ""
+"**: 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/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:38
+msgid "Author"
+msgstr "Autor"
+
+#: ../templates/classic/html/msg.item.html:43
+msgid "Recipients"
+msgstr "Destinatarios"
+
+#: ../templates/classic/html/msg.item.html:54
+msgid "Content"
+msgstr "Contenido"
+
+#: ../templates/classic/html/page.html:54
+#: ../templates/minimal/html/page.html:53
+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:65
+#: ../templates/minimal/html/page.html:64
+msgid "Issues"
+msgstr "Issues"
+
+#: ../templates/classic/html/page.html:67
+#: ../templates/classic/html/page.html:105
+#: ../templates/minimal/html/page.html:66
+#: ../templates/minimal/html/page.html:104
+msgid "Create New"
+msgstr "Crear"
+
+#: ../templates/classic/html/page.html:69
+#: ../templates/minimal/html/page.html:68
+msgid "Show Unassigned"
+msgstr "Mostrar no asignados"
+
+#: ../templates/classic/html/page.html:81
+#: ../templates/minimal/html/page.html:80
+msgid "Show All"
+msgstr "Mostrar todos"
+
+#: ../templates/classic/html/page.html:93
+#: ../templates/minimal/html/page.html:92
+msgid "Show issue:"
+msgstr "Mostrar issue:"
+
+#: ../templates/classic/html/page.html:108
+#: ../templates/minimal/html/page.html:107
+msgid "Edit Existing"
+msgstr "Editar existentes"
+
+#: ../templates/classic/html/page.html:114
+#: ../templates/minimal/html/page.html:113
+msgid "Administration"
+msgstr "Administración"
+
+#: ../templates/classic/html/page.html:116
+#: ../templates/minimal/html/page.html:115
+msgid "Class List"
+msgstr "Lista de clases"
+
+#: ../templates/classic/html/page.html:120
+#: ../templates/minimal/html/page.html:119
+msgid "User List"
+msgstr "Lista de usuarios"
+
+#: ../templates/classic/html/page.html:122
+#: ../templates/minimal/html/page.html:121
+msgid "Add User"
+msgstr "Agregar usuario"
+
+#: ../templates/classic/html/page.html:129
+#: ../templates/classic/html/page.html:135
+#: ../templates/minimal/html/page.html:128
+#: ../templates/minimal/html/page.html:134
+msgid "Login"
+msgstr "Ingresar"
+
+#: ../templates/classic/html/page.html:134
+#: ../templates/minimal/html/page.html:133
+msgid "Remember me?"
+msgstr "Recordarme?"
+
+#: ../templates/classic/html/page.html:138
+#: ../templates/classic/html/user.register.html:63
+#: ../templates/minimal/html/page.html:137
+#: ../templates/minimal/html/user.register.html:61
+msgid "Register"
+msgstr "Registrarse"
+
+#: ../templates/classic/html/page.html:141
+#: ../templates/minimal/html/page.html:140
+msgid "Lost&nbsp;your&nbsp;login?"
+msgstr "Olvidó&nbsp;su&nbsp;contraseña?"
+
+#: ../templates/classic/html/page.html:146
+#: ../templates/minimal/html/page.html:145
+msgid "Hello, ${user}"
+msgstr "Hola, ${user}"
+
+#: ../templates/classic/html/page.html:148
+msgid "Your Issues"
+msgstr "Sus Issues"
+
+#: ../templates/classic/html/page.html:160
+#: ../templates/minimal/html/page.html:147
+msgid "Your Details"
+msgstr "Sus datos personales"
+
+#: ../templates/classic/html/page.html:162
+#: ../templates/minimal/html/page.html:149
+msgid "Logout"
+msgstr "Salir"
+
+#: ../templates/classic/html/page.html:166
+#: ../templates/minimal/html/page.html:153
+msgid "Help"
+msgstr "Ayuda"
+
+#: ../templates/classic/html/page.html:167
+#: ../templates/minimal/html/page.html:154
+msgid "Roundup docs"
+msgstr "Doc. de Roundup"
+
+#: ../templates/classic/html/page.html:177
+#: ../templates/minimal/html/page.html:164
+msgid "clear this message"
+msgstr "quitar este mensaje"
+
+#: ../templates/classic/html/page.html:241
+#: ../templates/classic/html/page.html:256
+#: ../templates/classic/html/page.html:270
+#: ../templates/minimal/html/page.html:228
+#: ../templates/minimal/html/page.html:243
+#: ../templates/minimal/html/page.html:257
+msgid "don't care"
+msgstr "cualquier(a)"
+
+#: ../templates/classic/html/page.html:243
+#: ../templates/classic/html/page.html:258
+#: ../templates/classic/html/page.html:271
+#: ../templates/minimal/html/page.html:230
+#: ../templates/minimal/html/page.html:245
+#: ../templates/minimal/html/page.html:258
+msgid "------------"
+msgstr "------------"
+
+#: ../templates/classic/html/page.html:299
+#: ../templates/minimal/html/page.html:286
+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
+#: ../templates/classic/html/query.edit.html:92
+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:94
+msgid "[not yours to edit]"
+msgstr "[no puede editar una consulta que no le pertenece]"
+
+#: ../templates/classic/html/query.edit.html:102
+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.help-search.html:73
+msgid "Pagesize"
+msgstr "Tamaño de página"
+
+#: ../templates/classic/html/user.help.html:43
+msgid ""
+"Your browser is not capable of using frames; you should be redirected "
+"immediately, or visit ${link}."
+msgstr ""
+"Su navegador no tiene capacidad de manejar marcos; debería ser "
+"redireccionado de inmediato, caso contrario vaya a ${link}."
+
+#: ../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:19
+#: ../templates/minimal/html/user.index.html:19
+msgid "Username"
+msgstr "Nombre de usuario"
+
+#: ../templates/classic/html/user.index.html:20
+msgid "Real name"
+msgstr "Nombre real"
+
+#: ../templates/classic/html/user.index.html:21
+#: ../templates/classic/html/user.register.html:45
+msgid "Organisation"
+msgstr "Organización"
+
+#: ../templates/classic/html/user.index.html:22
+#: ../templates/minimal/html/user.index.html:20
+msgid "Email address"
+msgstr "Dirección de e-mail"
+
+#: ../templates/classic/html/user.index.html:23
+msgid "Phone number"
+msgstr "Nro. telefónico"
+
+#: ../templates/classic/html/user.index.html:24
+msgid "Retire"
+msgstr "Retirar"
+
+#: ../templates/classic/html/user.index.html:37
+msgid "retire"
+msgstr "retirar"
+
+#: ../templates/classic/html/user.item.html:9
+#: ../templates/minimal/html/user.item.html:9
+msgid "User ${id}: ${title} - ${tracker}"
+msgstr "Usuario ${id}: ${title} - ${tracker}"
+
+#: ../templates/classic/html/user.item.html:12
+#: ../templates/minimal/html/user.item.html:12
+msgid "New User - ${tracker}"
+msgstr "Nuevo usuario - ${tracker}"
+
+#: ../templates/classic/html/user.item.html:21
+#: ../templates/minimal/html/user.item.html:21
+msgid "New User"
+msgstr "Nuevo usuario"
+
+#: ../templates/classic/html/user.item.html:23
+#: ../templates/minimal/html/user.item.html:23
+msgid "New User Editing"
+msgstr "Edición de nuevo usuario"
+
+#: ../templates/classic/html/user.item.html:26
+#: ../templates/minimal/html/user.item.html:26
+msgid "User${id}"
+msgstr "Usuario${id}"
+
+#: ../templates/classic/html/user.item.html:29
+#: ../templates/minimal/html/user.item.html:29
+msgid "User${id} Editing"
+msgstr "Edición de Usuario${id}"
+
+#: ../templates/classic/html/user.item.html:80
+#: ../templates/classic/html/user.register.html:33
+#: ../templates/minimal/html/user.item.html:80
+#: ../templates/minimal/html/user.register.html:41
+msgid "Roles"
+msgstr "Roles"
+
+#: ../templates/classic/html/user.item.html:88
+#: ../templates/minimal/html/user.item.html:88
+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:109
+#: ../templates/minimal/html/user.item.html:109
+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:130
+#: ../templates/classic/html/user.register.html:53
+#: ../templates/minimal/html/user.item.html:130
+#: ../templates/minimal/html/user.register.html:53
+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.register.html:21
+#: ../templates/minimal/html/user.register.html:29
+msgid "Login Name"
+msgstr "Nombre para Login"
+
+#: ../templates/classic/html/user.register.html:25
+#: ../templates/minimal/html/user.register.html:33
+msgid "Login Password"
+msgstr "Contraseña para Login"
+
+#: ../templates/classic/html/user.register.html:29
+#: ../templates/minimal/html/user.register.html:37
+msgid "Confirm Password"
+msgstr "Confirmar contraseña"
+
+#: ../templates/classic/html/user.register.html:41
+msgid "Phone"
+msgstr "Teléfono"
+
+#: ../templates/classic/html/user.register.html:49
+#: ../templates/minimal/html/user.register.html:49
+msgid "E-mail address"
+msgstr "Dirección de e-mail"
+
+#: ../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."
+
+# priority translations:
+#: ../templates/classic/initial_data.py:5
+msgid "critical"
+msgstr "critical"
+
+#: ../templates/classic/initial_data.py:6
+msgid "urgent"
+msgstr "urgent"
+
+#: ../templates/classic/initial_data.py:7
+msgid "bug"
+msgstr "bug"
+
+#: ../templates/classic/initial_data.py:8
+msgid "feature"
+msgstr "feature"
+
+#: ../templates/classic/initial_data.py:9
+msgid "wish"
+msgstr "wish"
+
+#: ../templates/classic/initial_data.py:12
+msgid "unread"
+msgstr "unread"
+
+#: ../templates/classic/initial_data.py:13
+msgid "deferred"
+msgstr "deferred"
+
+#: ../templates/classic/initial_data.py:14
+msgid "chatting"
+msgstr "chatting"
+
+#: ../templates/classic/initial_data.py:15
+msgid "need-eg"
+msgstr "need-eg"
+
+#: ../templates/classic/initial_data.py:16
+msgid "in-progress"
+msgstr "in-progress"
+
+#: ../templates/classic/initial_data.py:17
+msgid "testing"
+msgstr "testing"
+
+#: ../templates/classic/initial_data.py:18
+msgid "done-cbb"
+msgstr "done-cbb"
+
+#: ../templates/classic/initial_data.py:19
+msgid "resolved"
+msgstr "resuelto"
+
+#: ../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."
+
+#~ msgid "topic"
+#~ msgstr "palabraclave"
+
+#~ msgid "System message:"
+#~ msgstr "Mensaje de sistema:"
+
+#~ msgid "List of issues - ${tracker}"
+#~ msgstr "Lista de issues - ${tracker}"
+
+#~ msgid "Topic"
+#~ msgstr "Palabra clave"
+
+#~ msgid "View: ${link}"
+#~ msgstr "Ver: ${link}"
+
+#~ msgid "Topics"
+#~ msgstr "Palabras clave"
+
+#~ msgid "Topic:"
+#~ msgstr "Palabra clave:"
+
+#~ msgid "Timezone"
+#~ msgstr "Zona horaria"
+
+#~ msgid "Hello,<br>${user}"
+#~ msgstr "Hola,<br>${user}"
+
+#~ msgid "User editing - ${tracker}"
+#~ msgstr "Edición de usuario - ${tracker}"
diff --git a/locale/fr.po b/locale/fr.po
new file mode 100644 (file)
index 0000000..77523e2
--- /dev/null
@@ -0,0 +1,3389 @@
+# French message file for Roundup Issue Tracker
+# Georges Martin <georges.martin@pi.be>, 2004.
+# Patrick Decat <pdecat@gmail.com>, 2008.
+# Stéphane Raimbault <stephane.raimbault@gmail.com>, 2008.
+# This file is distributed under the same license as the Roundup package.
+# 
+# $Id: fr.po,v 1.6 2009-02-05 14:18:27 stefan Exp $
+# roundup.pot revision 1.18
+# 
+msgid ""
+msgstr ""
+"Project-Id-Version: Roundup 1.4.6\n"
+"Report-Msgid-Bugs-To: roundup-devel@lists.sourceforge.net\n"
+"POT-Creation-Date: 2006-04-27 09:02+0300\n"
+"PO-Revision-Date: 2008-10-14 10:15+0200\n"
+"Last-Translator: Stéphane Raimbault <stephane.raimbault@gmail.com>\n"
+"Language-Team: GNOME French Team <gnomefr@traduc.org>\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=n>1;\n"
+
+# ../roundup/admin.py:85 :979 :1028 :1050
+# ../roundup/admin.py:1052 ../roundup/admin.py:85:981 :1030:1052
+#: ../roundup/admin.py:85 ../roundup/admin.py:981 ../roundup/admin.py:1030
+#: ../roundup/admin.py:1052
+#, python-format
+msgid "no such class \"%(classname)s\""
+msgstr "aucune classe nommée « %(classname)s »"
+
+# ../roundup/admin.py:95 :99
+# ../roundup/admin.py:95 ../roundup/admin.py:99 ../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"
+" -V                -- be verbose when importing\n"
+" -v                -- report Roundup and Python versions (and quit)\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)sUtilisation : roundup-admin [options] [<commande> <arguments>]\n"
+"\n"
+"Options :\n"
+" -i racine pisteur  -- indique le répertoire racine 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"
+" -V                 -- est verbeux à l'importation\n"
+" -v                 -- affiche les versions de Roundup et Python (et quitte).\n"
+"\n"
+" Seulement une des options parmi -s, -c ou -S peut être utilisée à la fois.\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:140
+msgid "Commands:"
+msgstr "Commandes :"
+
+#: ../roundup/admin.py:147
+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, dans le cas\n"
+"où l'abréviation ne correspond qu'à une seule commande,\n"
+"par ex. : l == li == lis == list."
+
+#: ../roundup/admin.py:177
+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"
+"d'indiquer un pisteur. Il s'agit juste du chemin vers le pisteur\n"
+"Roundup sur lequel vous désirez travailler. Un pisteur Roundup est\n"
+"l'endroit où Roundup conserver la base de données et les fichiers de\n"
+"configuration qui définissent un pisteur de problèmes. Il peut être\n"
+"indiqué dans la variable d'environnement « TRACKER_HOME » ou en ligne\n"
+"de commande comme « -i racine-du-pisteur ».\n"
+"\n"
+"Un indicateur est la concaténation d'un nom de classe et d'un\n"
+"identificateur de noeud, p. ex. : bug1, user10, ...\n"
+"\n"
+"Les valeurs de propriété sont représentées comme des chaînes de\n"
+"caractères dans les arguments de commande et dans les résultats\n"
+"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\n"
+"   fuseau horaire local et acceptées dans le format complet ou l'un\n"
+"   des formats partiels expliqués ci-dessous.\n"
+" . Les valeurs de liens 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 tous 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 sous\n"
+"   forme de chaîne de caractères sont acceptés ; une chaîne de\n"
+"   caractères vide, un noeud seul ou une liste de noeuds séparés par\n"
+"   des virgules sont acceptés.\n"
+"\n"
+"Lorsque des valeurs de propriétés doivent contenir des espaces,\n"
+"entourez simplement la valeur avec des guillements simples « ' » ou\n"
+"doubles « \" ».  Une espace seule peut également être protégée par un\n"
+"anti-slash. Si une valeur doit contenir un guillemet, il doit être\n"
+"protégé par un anti-slash ou être placé entre guillemets. Par\n"
+"exemple :\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 indiqués aux commandes roundup « get »\n"
+"ou « set », les propriétés sont extraites ou assignées à tous ces\n"
+"noeuds.\n"
+"\n"
+"Lorsque plusieurs résultats sont renvoyés par les commandes roundup\n"
+"« get » ou « set », ils sont, par défaut, imprimés un par ligne ou,\n"
+"avec l'option -c, séparés par des virgules.\n"
+"\n"
+"Lorsqu'une commande modifie des données, une authentification par nom\n"
+"et mot de passe est requise. L'authentification peut être donnée soit\n"
+"comme « nom », 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 à\n"
+"la 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:240
+#, python-format
+msgid "%s:"
+msgstr "%s :"
+
+#: ../roundup/admin.py:245
+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 ""
+"Utilisation : help sujet\n"
+"        Affiche 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:268
+#, python-format
+msgid "Sorry, no help for \"%(topic)s\""
+msgstr "Désolé, aucune aide n'est disponible au sujet de « %(topic)s »"
+
+# ../roundup/admin.py:338 :394
+# ../roundup/admin.py:340 ../roundup/admin.py:396 ../roundup/admin.py:340:396
+#: ../roundup/admin.py:340 ../roundup/admin.py:396
+msgid "Templates:"
+msgstr "Modèles :"
+
+# ../roundup/admin.py:341 :405
+# ../roundup/admin.py:343 ../roundup/admin.py:407 ../roundup/admin.py:343:407
+#: ../roundup/admin.py:343 ../roundup/admin.py:407
+msgid "Back ends:"
+msgstr "Moteurs de stockage :"
+
+#: ../roundup/admin.py:346
+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 ""
+"Utilisation : 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\n"
+"        (s'il n'est pas fourni par la variable d'environnement\n"
+"        TRACKER_HOME ou l'option -i).  Le modèle, le moteur de\n"
+"        stockage et le mot de passe d'administration peuvent être\n"
+"        renseignés dans cet ordre comme arguments dans la ligne de\n"
+"        commande.\n"
+"\n"
+"        Le dernier argument de la ligne de commande permet de préciser\n"
+"        des valeurs initiales pour les options de configuration. Par\n"
+"        exemple, « web_http_auth=no,rdbms_user=dinsdale » remplacera\n"
+"        les valeurs par défaut pour les options http_auth dans la\n"
+"        section [web] and user dans la in section [rdbms]. Soyez\n"
+"        attentifs à ne pas mettre d'espace dans cet argument (protéger\n"
+"        les arguments avec des guillemets si vous devez préciser des\n"
+"        contenant des espaces).\n"
+"\n"
+"        La commande « initialise » doît être appelée après cette\n"
+"        commande, pour initialiser la base de données du pisteur. Vous\n"
+"        pouvez modifier le contenu initial de la base de données avant\n"
+"        d'exécuter cette commande en modifiant la fonction init() du\n"
+"        module dbinit.py du pisteur.\n"
+"\n"
+"        Consultez é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:1207 ../roundup/admin.py:369:466 :1020:1042 :1069:1136
+# :1207 :527:606 :656:714 :735:763 :834:901:972
+#: ../roundup/admin.py:369 ../roundup/admin.py:466 ../roundup/admin.py:527
+#: ../roundup/admin.py:606 ../roundup/admin.py:656 ../roundup/admin.py:714
+#: ../roundup/admin.py:735 ../roundup/admin.py:763 ../roundup/admin.py:834
+#: ../roundup/admin.py:901 ../roundup/admin.py:972 ../roundup/admin.py:1020
+#: ../roundup/admin.py:1042 ../roundup/admin.py:1069 ../roundup/admin.py:1136
+#: ../roundup/admin.py:1207
+msgid "Not enough arguments supplied"
+msgstr "Pas suffisamment d'arguments fournis"
+
+#: ../roundup/admin.py:375
+#, 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:383
+#, 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"
+"Supprimer le pisteur (Y/N) ? "
+
+#: ../roundup/admin.py:398
+msgid "Select template [classic]: "
+msgstr "Sélection du modèle [classic] : "
+
+#: ../roundup/admin.py:409
+msgid "Select backend [anydbm]: "
+msgstr "Sélection du moteur de stockage [anydbm]: "
+
+#: ../roundup/admin.py:419
+#, python-format
+msgid "Error in configuration settings: \"%s\""
+msgstr "Erreur dans les paramètres de la configuration : « %s »"
+
+#: ../roundup/admin.py:428
+#, python-format
+msgid ""
+"\n"
+"---------------------------------------------------------------------------\n"
+" You should now edit the tracker configuration file:\n"
+"   %(config_file)s"
+msgstr ""
+"\n"
+"---------------------------------------------------------------------------\n"
+" Vous devez maintenant modifier le fichier de configuration du pisteur :\n"
+"    %(config_file)s"
+
+#: ../roundup/admin.py:438
+msgid " ... at a minimum, you must set following options:"
+msgstr " ou au minimum, vous devez définir les options suivantes :"
+
+#: ../roundup/admin.py:443
+#, 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 vous souhaitez modifier le schéma de la base de données, vous\n"
+" devez aussi modifier la schéma du fichier :\n"
+"   %(database_config_file)s\n"
+"\n"
+" Vous pouvez aussi modifier le fichier d'initialisation de la base de\n"
+" données :\n"
+"   %(database_init_file)s\n"
+"\n"
+" Consultez la documentation sur la personnalisation pour plus\n"
+" d'informations.\n"
+"\n"
+" Vous DEVEZ exécuter la commande « roundup-admin initialise » une fois\n"
+" que vous avez réalisé les étapes précédentes.\n"
+"---------------------------------------------------------------------------\n"
+
+#: ../roundup/admin.py:461
+msgid ""
+"Usage: genconfig <filename>\n"
+"        Generate a new tracker config file (ini style) with default values\n"
+"        in <filename>.\n"
+"        "
+msgstr ""
+"Utilisation : genconfig <nomfichier>\n"
+"              Génère un nouveau fichier de configuration du pisteur\n"
+"              (au format ini) avec des valeurs par défaut dans\n"
+"              <nomfichier>"
+
+#. password
+#: ../roundup/admin.py:471
+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 ""
+"Utilisation : initialise [adminpw]\n"
+"        Initialise un nouveau pisteur Roundup.\n"
+"\n"
+"        Les détails au sujet de l'administrateur sont définis au cours\n"
+"        de cette étape.\n"
+"\n"
+"        Exécute la fonction d'initialisation dbinit.init() du pisteur.\n"
+"        "
+
+#: ../roundup/admin.py:485
+msgid "Admin Password: "
+msgstr "Mot de passe administrateur : "
+
+#: ../roundup/admin.py:486
+msgid "       Confirm: "
+msgstr "       Confirmez : "
+
+#: ../roundup/admin.py:490
+msgid "Instance home does not exist"
+msgstr "Le répertoire racine de l'instance n'existe pas"
+
+#: ../roundup/admin.py:494
+msgid "Instance has not been installed"
+msgstr "L'instance n'a pas été installée"
+
+#: ../roundup/admin.py:499
+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"
+"Supprimez la base de données (Y/N) ? "
+
+#: ../roundup/admin.py:520
+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 ""
+"Utilisation : get property indicateur[,indicateur]*\n"
+"        Retourne la propriété demandée d'un ou plusieurs indicateurs.\n"
+"\n"
+"        Retourne la valeur de la propriété des noeuds spécifiés par\n"
+"        les indicateurs.\n"
+"        "
+
+# ../roundup/admin.py:558 :573
+# ../roundup/admin.py:560 ../roundup/admin.py:575 ../roundup/admin.py:560:575
+#: ../roundup/admin.py:560 ../roundup/admin.py:575
+#, 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:1054 ../roundup/admin.py:583:983 :1032:1054
+#: ../roundup/admin.py:583 ../roundup/admin.py:983 ../roundup/admin.py:1032
+#: ../roundup/admin.py:1054
+#, 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:585
+#, 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:594
+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 ""
+"Utilisation : 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 indiqués par une classe ou par une liste\n"
+"        d'indicateurs séparés par des virgules (par\n"
+"        ex. « indicateur[,indicateur,...] »).\n"
+"\n"
+"        Cette commande assigne les valeurs données aux propriétés de\n"
+"        tous les indicateurs indiqués. Si la valeur est absente (par\n"
+"        ex. « propriété= ») alors la propriété est effacée. Si la\n"
+"        propriété est un lien multiple, les identificateurs attachés à\n"
+"        ce lien sont indiqués comme des nombres séparés par des\n"
+"        virgules (par ex. « 1,2,3 »)."
+
+#: ../roundup/admin.py:648
+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 ""
+"Utilisation : find nom-de-classe propriété=valeur ...\n"
+"        Recherche les noeuds de la classe indiquée, ayant une propriété de\n"
+"        lien donnée.\n"
+"\n"
+"        Recherche les noeuds de la classe indiqué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:920 ../roundup/admin.py:701:854 :866:920
+#: ../roundup/admin.py:701 ../roundup/admin.py:854 ../roundup/admin.py:866
+#: ../roundup/admin.py:920
+#, python-format
+msgid "%(classname)s has no property \"%(propname)s\""
+msgstr "%(classname)s n'a pas de propriété « %(propname)s »"
+
+#: ../roundup/admin.py:708
+msgid ""
+"Usage: specification classname\n"
+"        Show the properties for a classname.\n"
+"\n"
+"        This lists the properties for a given class.\n"
+"        "
+msgstr ""
+"Utilisation : specification nom-de-classe\n"
+"        Affiche les propriétés de la classe nommée.\n"
+"\n"
+"        Cette commande énumère les propriétés de la classe nommée.\n"
+"        "
+
+#: ../roundup/admin.py:723
+#, python-format
+msgid "%(key)s: %(value)s (key property)"
+msgstr "%(key)s : %(value)s (propriété clé)"
+
+#: ../roundup/admin.py:725
+#, python-format
+msgid "%(key)s: %(value)s"
+msgstr "%(key)s : %(value)s"
+
+#: ../roundup/admin.py:728
+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 ""
+"Utilisation : display indicateur[,indicateur]*\n"
+"        Affiche les valeurs des propriétés des noeuds indiqués.\n"
+"\n"
+"        Cette commande énumère les propriétés et leurs valeurs du ou\n"
+"        des noeuds indiqués.\n"
+"        "
+
+#: ../roundup/admin.py:752
+#, python-format
+msgid "%(key)s: %(value)r"
+msgstr "%(key)s: %(value)r"
+
+#: ../roundup/admin.py:755
+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 ""
+"Utilisation : 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 indiquée\n"
+"        en utilisant les propriétés « nom=valeur » données en\n"
+"        arguments de la ligne de commande, après la commande\n"
+"        « create ».\n"
+"        "
+
+#: ../roundup/admin.py:782
+#, python-format
+msgid "%(propname)s (Password): "
+msgstr "%(propname)s (mot de passe) : "
+
+#: ../roundup/admin.py:784
+#, python-format
+msgid "   %(propname)s (Again): "
+msgstr "   %(propname)s (à nouveau) : "
+
+#: ../roundup/admin.py:786
+msgid "Sorry, try again..."
+msgstr "Désolé, essayez à nouveau..."
+
+#: ../roundup/admin.py:790
+#, python-format
+msgid "%(propname)s (%(proptype)s): "
+msgstr "%(propname)s (%(proptype)s) : "
+
+#: ../roundup/admin.py:808
+#, python-format
+msgid "you must provide the \"%(propname)s\" property."
+msgstr "vous devez renseigner la propriété « %(propname)s »."
+
+#: ../roundup/admin.py:819
+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 ""
+"Utilisation: list nom-de-classe [propriété]\n"
+"        Liste toutes les instances d'une classe.\n"
+"\n"
+"        Énumère toutes les instances d'une classe donnée. Si la\n"
+"        propriété n'est pas indiquée, la propriété « label » est\n"
+"        utilisée. Cette propriété étiquette est déterminée selon\n"
+"        l'ordre suivant : la clé, les propriétés « name », « title »\n"
+"        et la première propriété par 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 indiquée.\n"
+"        Si une propriété est indiquée, affiche une liste de cette\n"
+"        propriété pour chaque instance de cette classe.\n"
+"        "
+
+#: ../roundup/admin.py:832
+msgid "Too many arguments supplied"
+msgstr "Trop d'arguments fournis"
+
+#: ../roundup/admin.py:868
+#, python-format
+msgid "%(nodeid)4s: %(value)s"
+msgstr "%(nodeid)4s : %(value)s"
+
+#: ../roundup/admin.py:872
+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 ""
+"Utilisation : 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"
+"        indiqué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 indiquée explicitement en définissant\n"
+"        la propriété comme « nom-de-propriété:largeur ».\n"
+"        Par exemple :\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 exemple :\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:916
+#, python-format
+msgid "\"%(spec)s\" not name:width"
+msgstr "« %(spec)s » ne correspond pas au format « nom:largeur »"
+
+#: ../roundup/admin.py:966
+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 ""
+"Utilisation : history indicateur\n"
+"        Affiche le journal des entrées d'un indicateur.\n"
+"\n"
+"        Liste les entrées de journal pour le noeud identifié par\n"
+"        l'indicateur.\n"
+"        "
+
+#: ../roundup/admin.py:987
+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 ""
+"Utilisation : 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\n"
+"        sont pas automatiquement enregistrés dans la base de données -\n"
+"        ils doivent être validés par cette commande.\n"
+"\n"
+"        Les commandes « one-off » en ligne de commande sont\n"
+"        automatiquement validées si elles réussissent.\n"
+"        "
+
+#: ../roundup/admin.py:1001
+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 ""
+"Utlisation : rollback\n"
+"        Annule tous 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\n"
+"        sont pas automatiquement enregistrés dans la base de données -\n"
+"        ils doivent être validés manuellement. Cette commande annule\n"
+"        tout ces changements, de telle manière qu'une validation\n"
+"        effectuée immédiatement n'apporterait aucun changement à la\n"
+"        base de données.\n"
+"        "
+
+#: ../roundup/admin.py:1013
+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 ""
+"Utilisation : retire indicateur[,indicateur]*\n"
+"        Retire le noeud indiqué 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\n"
+"        valeur de clé peut être ré-utilisée.\n"
+"        "
+
+#: ../roundup/admin.py:1036
+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 ""
+"Utilisaiton : restore indicateur[,indicateur]*\n"
+"        Restaure le ou les noeuds retirés, indiqués par le ou les\n"
+"        indicateurs.\n"
+"\n"
+"        Les noeuds indiqués seront à nouveau acessibles aux\n"
+"        utilisateurs.\n"
+"        "
+
+#. grab the directory to export to
+#: ../roundup/admin.py:1058
+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 ""
+"Utilisation : 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 indiqué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:1116
+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 ""
+"Utilisation: 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:1189
+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 ""
+"Utilisation: pack période | date\n"
+"\n"
+"        Efface les entrées de journaux antérieures à une période ou à\n"
+"        une date donnée.\n"
+"\n"
+"        Une période est indiquée en utilisant les suffixes « y » (pour\n"
+"        « year » - année), « m » (pour « month » - mois), et « d »\n"
+"        (pour « 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 exemple :\n"
+"              2001-01-01\n"
+"\n"
+"        "
+
+#: ../roundup/admin.py:1217
+msgid "Invalid format"
+msgstr "Format non valide"
+
+#: ../roundup/admin.py:1227
+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 ""
+"Utilisation: 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 opération est normalement effectuer automatiquement.\n"
+"        "
+
+#: ../roundup/admin.py:1241
+#, python-format
+msgid "no such item \"%(designator)s\""
+msgstr "pas d'élément « %(designator)s »"
+
+#: ../roundup/admin.py:1251
+msgid ""
+"Usage: security [Role name]\n"
+"        Display the Permissions available to one or all Roles.\n"
+"        "
+msgstr ""
+"Utilisation : security [nom-de-rôle]\n"
+"        Affiche les permissions disponible pour un ou plusieurs rôles.\n"
+"        "
+
+#: ../roundup/admin.py:1259
+#, python-format
+msgid "No such Role \"%(role)s\""
+msgstr "Ce rôle « %(role)s » n'existe pas"
+
+#: ../roundup/admin.py:1265
+#, 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:1267
+#, 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:1270
+#, python-format
+msgid "New Email users get the Roles \"%(role)s\""
+msgstr "Les nouveaux utilisateurs Courriel ont les rôles « %(role)s »"
+
+#: ../roundup/admin.py:1272
+#, python-format
+msgid "New Email users get the Role \"%(role)s\""
+msgstr "Les nouveaux utilisateurs Courriel ont le rôle « %(role)s »"
+
+#: ../roundup/admin.py:1275
+#, python-format
+msgid "Role \"%(name)s\":"
+msgstr "Rôle « %(name)s » :"
+
+#: ../roundup/admin.py:1280
+#, python-format
+msgid " %(description)s (%(name)s for \"%(klass)s\": %(properties)s only)"
+msgstr " %(description)s (%(name)s pour « %(klass)s » : %(properties)s uniquement)"
+
+#: ../roundup/admin.py:1283
+#, python-format
+msgid " %(description)s (%(name)s for \"%(klass)s\" only)"
+msgstr " %(description)s (%(name)s pour « %(klass)s » uniquement)"
+
+#: ../roundup/admin.py:1286
+#, python-format
+msgid " %(description)s (%(name)s)"
+msgstr " %(description)s (%(name)s)"
+
+#: ../roundup/admin.py:1315
+#, 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:1321
+#, python-format
+msgid "Multiple commands match \"%(command)s\": %(list)s"
+msgstr "Plusieurs commandes correspondent à « %(command)s » : %(list)s"
+
+#: ../roundup/admin.py:1328
+msgid "Enter tracker home: "
+msgstr "Saisissez le répertoire racine du pisteur : "
+
+# ../roundup/admin.py:1332 :1338 :1358
+# ../roundup/admin.py:1335:1341:1361
+#: ../roundup/admin.py:1335 ../roundup/admin.py:1341 ../roundup/admin.py:1361
+#, python-format
+msgid "Error: %(message)s"
+msgstr "Erreur : %(message)s"
+
+#: ../roundup/admin.py:1349
+#, python-format
+msgid "Error: Couldn't open tracker: %(message)s"
+msgstr "Erreur : impossible d'ouvrir le pisteur, %(message)s"
+
+#: ../roundup/admin.py:1374
+#, python-format
+msgid ""
+"Roundup %s ready for input.\n"
+"Type \"help\" for help."
+msgstr ""
+"Roundup %s est prêt pour la saisie.\n"
+"Saisissez « help » pour l'aide."
+
+#: ../roundup/admin.py:1379
+msgid "Note: command history and editing not available"
+msgstr "Note : l'historique et l'édition des commandes n'est pas disponible"
+
+#: ../roundup/admin.py:1383
+msgid "roundup> "
+msgstr "roundup> "
+
+#: ../roundup/admin.py:1385
+msgid "exit..."
+msgstr "sortie..."
+
+#: ../roundup/admin.py:1395
+msgid "There are unsaved changes. Commit them (y/N)? "
+msgstr "Des changements n'ont pas été enregistrés, les valider (y/N) ?"
+
+#: ../roundup/backends/back_anydbm.py:2001
+#, python-format
+msgid "WARNING: invalid date tuple %r"
+msgstr "ATTENTION : tuple de date non valide %r"
+
+#: ../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 "retiré"
+
+#: ../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 saisi"
+
+#: ../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é retiré"
+
+# ../roundup/cgi/actions.py:174 :202
+# ../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 "Vous n'avez pas la permission de modifier des requêtes"
+
+# ../roundup/cgi/actions.py:180 :209
+# ../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 d'enregistrer des requêtes"
+
+#: ../roundup/cgi/actions.py:297
+#, python-format
+msgid "Not enough values on line %(line)s"
+msgstr "Pas suffisament de valeurs sur la ligne %(line)s"
+
+#: ../roundup/cgi/actions.py:344
+msgid "Items edited OK"
+msgstr "Les éléments ont été modifiés avec succè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) avec succè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 modifié ce %s (%s). Consultez <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: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 n'est pas valide.\n"
+"Un bug dans Mozilla peut provoquer une apparition erronée de ce message, vérifiez 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 électronique inconnue"
+
+#: ../roundup/cgi/actions.py:697
+msgid "You need to specify a username or address"
+msgstr "Vous devez indiquer un nom d'utilisateur ou une adresse électronique"
+
+#: ../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 "Impossible de renseigner les rôles à l'inscription."
+
+#: ../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:934
+#: ../roundup/cgi/actions.py:930 ../roundup/cgi/actions.py:934
+msgid "Invalid login"
+msgstr "Tentative de connexion non valide"
+
+#: ../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 actuelles :</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:178
+#: ../roundup/cgi/cgitb.py:172 ../roundup/cgi/cgitb.py:178
+msgid "<em>undefined</em>"
+msgstr "<em>indé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 "Jeu de caractères non reconnu : %r"
+
+#: ../roundup/cgi/client.py:491
+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:646
+msgid "You are not allowed to view this file."
+msgstr "Vous n'êtes pas autorisé à voir ce fichier"
+
+#: ../roundup/cgi/client.py:738
+#, python-format
+msgid "%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n"
+msgstr "%(starttag)sTemps écoulé: %(seconds)fs%(endtag)s\n"
+
+#: ../roundup/cgi/client.py:742
+#, 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, manqué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: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: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:1875 ../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 n~ud - 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: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:1431 ../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:1428 ../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 "*crypté*"
+
+#: ../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 %(number)s/4"
+
+#: ../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] "%(number)s/4 d'heures"
+
+#: ../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/mailgw.py:586
+msgid ""
+"\n"
+"Emails to Roundup trackers must include a Subject: line!\n"
+msgstr "\nLes courriels envoyés au gestionnaire de ticket doivent comporter un sujet !\n"
+
+#: ../roundup/mailgw.py:674
+#, python-format
+msgid ""
+"\n"
+"The message you sent to roundup did not contain a properly formed subject\n"
+"line. The subject must contain a class name or designator to indicate the\n"
+"'topic' of the message. For example:\n"
+"    Subject: [issue] This is a new issue\n"
+"      - this will create a new issue in the tracker with the title 'This is\n"
+"        a new issue'.\n"
+"    Subject: [issue1234] This is a followup to issue 1234\n"
+"      - this will append the message's contents to the existing issue 1234\n"
+"        in the tracker.\n"
+"\n"
+"Subject was: '%(subject)s'\n"
+msgstr ""
+"\n"
+"Le sujet du message que vous avez envoyé au gestionnaire de ticket n'était\n"
+"pas correct. Le sujet doit contenir le nom d'une classe ou d'un objet. Par\n"
+"exemple : \n"
+"   Sujet: [issue] Un nouveau ticket\n"
+"     - créera dans le gestionnaire un nouveau ticket dont le titre\n"
+"       sera « Un nouveau ticket ».\n"
+"\n"
+"   Sujet: [issue1234] Réponse au ticket 1234\n"
+"     - ajoutera le corps du message au ticket 1234 déjà présent dans \n"
+"       le gestionnaire.\n"
+"\n"
+"Sujet original : '%(subject)s'\n"
+
+#: ../roundup/mailgw.py:705
+#, python-format
+msgid ""
+"\n"
+"The class name you identified in the subject line (\"%(classname)s\") does "
+"not exist in the\n"
+"database.\n"
+"\n"
+"Valid class names are: %(validname)s\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"\n"
+"Le nom de la classe identifiée dans le sujet (« %(classname)s »)\n"
+"n'existe pas dans la base de données.\n"
+"\n"
+"Les noms de classes valides sont : %(validname)s\n"
+"Sujet original : « %(subject)s »\n"
+
+#: ../roundup/mailgw.py:733
+#, python-format
+msgid ""
+"\n"
+"I cannot match your message to a node in the database - you need to either\n"
+"supply a full designator (with number, eg \"[issue123]\" or keep the\n"
+"previous subject title intact so I can match that.\n"
+"\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"\n"
+"Impossible d'associer votre message à un objet de la base de données.\n"
+"Vous devez soit fournir soit le nom d'une classe avec un numéro (par\n"
+"exemple \"[issue123]\"), soit garder le sujet du précédent message tel\n"
+"quel pour pouvoir effectuer la correspondance.\n"
+"\n"
+"Sujet original : « %(subject)s »\n"
+
+#: ../roundup/mailgw.py:766
+#, python-format
+msgid ""
+"\n"
+"The node specified by the designator in the subject of your message\n"
+"(\"%(nodeid)s\") does not exist.\n"
+"\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"\n"
+"L'objet indiqué dans le sujet de votre message (« %(nodeid)s »)\n"
+" n'existe pas.\n"
+"\n"
+"Sujet original : « %(subject)s »\n"
+
+#: ../roundup/mailgw.py:794
+#, python-format
+msgid ""
+"\n"
+"The mail gateway is not properly set up. Please contact\n"
+"%(mailadmin)s and have them fix the incorrect class specified as:\n"
+"  %(current_class)s\n"
+msgstr ""
+"\n"
+"La passerelle courriel ne fonctionne pas correctement. Contactez\n"
+"%(mailadmin)s afin qu'il corrige la classe incorrecte qui a été\n"
+"indiquée comme : \n"
+"  %(current_class)s\n"
+
+#: ../roundup/mailgw.py:817
+#, python-format
+msgid ""
+"\n"
+"The mail gateway is not properly set up. Please contact\n"
+"%(mailadmin)s and have them fix the incorrect properties:\n"
+"  %(errors)s\n"
+msgstr ""
+"\n"
+"La passerelle courriel ne fonctionne pas correctement. Contactez\n"
+"%(mailadmin)s afin que les propriétés incorrectes suivantes soient\n"
+"corrigés :\n"
+"  %(errors)s\n"
+
+#: ../roundup/mailgw.py:847
+#, python-format
+msgid ""
+"\n"
+"You are not a registered user.\n"
+"\n"
+"Unknown address: %(from_address)s\n"
+msgstr ""
+"\n"
+"Vous n'êtes pas un utilisateur inscrit.\n"
+"\n"
+"Addresse inconnue : %(from_address)s\n"
+
+#: ../roundup/mailgw.py:855
+msgid "You are not permitted to access this tracker."
+msgstr "Vous n'êtes pas autorisé à accéder à ce pisteur."
+
+#: ../roundup/mailgw.py:862
+#, python-format
+msgid "You are not permitted to edit %(classname)s."
+msgstr "Vous n'avez pas la permission de modifier %(classname)s"
+
+#: ../roundup/mailgw.py:866
+#, python-format
+msgid "You are not permitted to create %(classname)s."
+msgstr "Vous n'avez pas la permission de créer  %(classname)s"
+
+#: ../roundup/mailgw.py:913
+#, python-format
+msgid ""
+"\n"
+"There were problems handling your subject line argument list:\n"
+"- %(errors)s\n"
+"\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"\n"
+"Une erreur s'est produite lors du traitement de la liste de sujets :\n"
+"- %(errors)s\n"
+"\n"
+"Le sujet était « %(subject)s »\n"
+
+#: ../roundup/mailgw.py:942
+msgid ""
+"\n"
+"Roundup requires the submission to be plain text. The message parser could\n"
+"not find a text/plain part to use.\n"
+msgstr ""
+"\n"
+"Le message soumis doit être en texte brut. L'analyse du message n'a pas trouvé\n"
+"de partie text/plain à utiliser.\n"
+
+#: ../roundup/mailgw.py:964
+msgid "You are not permitted to create files."
+msgstr "Vous n'êtes pas autorisé à créer des fichiers."
+
+#: ../roundup/mailgw.py:978
+#, python-format
+msgid "You are not permitted to add files to %(classname)s."
+msgstr ""
+"Vous n'avez pas la permission d'ajouter des fichiers à la classe %(classname)"
+"s."
+
+#: ../roundup/mailgw.py:996
+msgid "You are not permitted to create messages."
+msgstr "Vous n'avez pas la permission de créer des messages."
+
+#: ../roundup/mailgw.py:1004
+#, python-format
+msgid ""
+"\n"
+"Mail message was rejected by a detector.\n"
+"%(error)s\n"
+msgstr ""
+"\n"
+"Le message a été rejeté par un détecteur.\n"
+"%(error)s\n"
+
+#: ../roundup/mailgw.py:1012
+#, python-format
+msgid "You are not permitted to add messages to %(classname)s."
+msgstr "Vous n'avez pas la permission d'ajouter des messages à %(classname)s."
+
+#: ../roundup/mailgw.py:1039
+#, python-format
+msgid "You are not permitted to edit property %(prop)s of class %(classname)s."
+msgstr ""
+"Vous n'avez pas la permission de modifier la propriété %(prop)s de la classe "
+"%(classname)s."
+
+#: ../roundup/mailgw.py:1047
+#, python-format
+msgid ""
+"\n"
+"There was a problem with the message you sent:\n"
+"   %(message)s\n"
+msgstr ""
+"\n"
+"Un problème a eu lieu à l'envoi de votre message :\n"
+"   %(message)s\n"
+
+#: ../roundup/mailgw.py:1069
+msgid "not of form [arg=value,value,...;arg=value,value,...]"
+msgstr "pas de la forme [arg=value,value,...;arg=value,value,...]"
+
+#: ../roundup/roundupdb.py:142
+msgid "files"
+msgstr "fichiers"
+
+#: ../roundup/roundupdb.py:142
+msgid "messages"
+msgstr "messages"
+
+#: ../roundup/roundupdb.py:142
+msgid "nosy"
+msgstr "curieux"
+
+#: ../roundup/roundupdb.py:142
+msgid "superseder"
+msgstr "remplaçant"
+
+#: ../roundup/roundupdb.py:142
+msgid "title"
+msgstr "titre"
+
+#: ../roundup/roundupdb.py:143
+msgid "assignedto"
+msgstr "affecté_à"
+
+#: ../roundup/roundupdb.py:143
+msgid "priority"
+msgstr "priorité"
+
+#: ../roundup/roundupdb.py:143
+msgid "status"
+msgstr "état"
+
+#: ../roundup/roundupdb.py:143
+msgid "topic"
+msgstr "sujet"
+
+#: ../roundup/roundupdb.py:146
+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:146
+msgid "actor"
+msgstr "acteur"
+
+#: ../roundup/roundupdb.py:146
+msgid "creation"
+msgstr "création"
+
+#: ../roundup/roundupdb.py:146
+msgid "creator"
+msgstr "créateur"
+
+#: ../roundup/roundupdb.py:304
+#, python-format
+msgid "New submission from %(authname)s%(authaddr)s:"
+msgstr "Nouvel envoi de %(authname)s%(authaddr)s :"
+
+#: ../roundup/roundupdb.py:307
+#, python-format
+msgid "%(authname)s%(authaddr)s added the comment:"
+msgstr "%(authname)s%(authaddr)s a ajouté le commentaire :"
+
+#: ../roundup/roundupdb.py:310
+msgid "System message:"
+msgstr "Message système :"
+
+#: ../roundup/scripts/roundup_demo.py:32
+#, python-format
+msgid "Enter directory path to create demo tracker [%s]: "
+msgstr "Saisissez le chemin du répertoire où créer le pisteur de démonstration [%s] : "
+
+#: ../roundup/scripts/roundup_gettext.py:22
+#, python-format
+msgid "Usage: %(program)s <tracker home>"
+msgstr "Utilisation : %(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@server\n"
+" The username and password may be omitted:\n"
+"    pop username@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@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@server\n"
+" It also allows you to specify a specific mailbox other than INBOX using\n"
+" this format:\n"
+"    imap username:password@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@server [mailbox]\n"
+"\n"
+msgstr ""
+"Utilisation : %(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-dessous.\n"
+"\n"
+"La passerelle de messagerie de Roundup peut être appelée de quatre façons :\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\n"
+"d'assigner des champs pour une classe créée par roundup-mailgw. La\n"
+"classe par défaut, si elle n'est pas spécifiée, est « msg », mais les\n"
+"autres classes : « issue » (anomalie), « file » (fichier), « user »\n"
+"(utilisateur) peuvent également être utilisées. Les options -S ou\n"
+"--set utilisent la même notation propriété=valeur[;propriété=valeur]\n"
+"acceptée par la ligne de commande de Roundup ou par les commandes qui\n"
+"peuvent être données dans l'objet d'un courriel.\n"
+"\n"
+"Elle vous permet également de spécifier le type de message pour chaque\n"
+"adresse 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 tous les messages ont été\n"
+" traités avec succès. Le fichier est indiqué 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"
+" indiqué et les soumet chacun à leur tour au module roundup.mailgw. Le\n"
+" serveur est renseigné comme suit :\n"
+"    pop nom-d'utilisateur:mot-de-passe@serveur\n"
+" Le nom d'utilisateur et le mot de passe peuvent être omis :\n"
+"    pop nom-d'utilisateur@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@serveur\n"
+"\n"
+"IMAP :\n"
+" Se connecte à un serveur IMAP. Il prend en charge la même notation\n"
+" que pour la messagerie POP\n"
+"\n"
+"    imap nom-d'utilisateur:mot-de-passe@serveur\n"
+" Il permet également d'indiquer une boîte aux lettres spécifique, autre\n"
+" que INBOX, en utilisant ce format :\n"
+"    imap nom-d'utilisateur:mot-de-passe@serveur boîte-aux-lettres\n"
+"\n"
+"IMAPS :\n"
+" Se connecte avec SSL à un serveur IMAP.\n"
+" Prend en charge la même notation que IMAP.\n"
+"    imaps nom-d'utilisateur:mot-de-passe@serveur [boîte-aux-lettres]\n"
+"\n"
+
+#: ../roundup/scripts/roundup_mailgw.py:147
+msgid "Error: not enough source specification information"
+msgstr "Erreur : pas suffisament 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\n"
+"               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 ""
+"%(message)sUtilisation : roundup-server [options] [name=tracker home]*\n"
+"\n"
+"Options :\n"
+" -v             affiche le numéro de version Roundup et quitte\n"
+" -h             affiche ce texte et quitte\n"
+" -S             crée ou met à jour le fichier de configuration et quitte\n"
+" -C <fichier>   utilise le fichier de configuration <fichier>\n"
+" -n <nom>       définit le nom d'hôte de l'instance Web Roundup\n"
+" -p <port>      définit le port d'écoute (par défaut %(port)s)\n"
+" -l <fichier>   historise dans le fichier indiqué par <fichier> au lieu de \n"
+"                stderr/stdout\n"
+" -N             historise le nom d'hôte client au lieu des adresses IP \n"
+"                (beaucoup plus lent)\n"
+" -t <mode>      mode multi-processus (par défaut %(mp_def)s). Valeurs\n"
+"                utilisées : %(mp_types)s.\n"
+"\n"
+"%(os_part)s\n"
+"\n"
+"Options longues :\n"
+" --version            affiche le numéro de version Roundup et quitte\n"
+" --help               affiche ce texte et quitte\n"
+" --save-config        crée ou met à jour le fichier de configuration et quitte\n"
+" --config <fichier>   utilise le fichier de configuration <fichier>\n"
+"\n"
+"Exemples :\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"
+"Format du fichier de configuration :\n"
+"\n"
+"   Le fichier de configuration du serveur Roundup utilise le format de\n"
+"   fichier commun .ini. Le fichier de configuration créé par\n"
+"   « roundup-server -S » contient les explications détaillées de\n"
+"   chaque option. Consultez ce fichier pour la description des\n"
+"   options.\n"
+"\n"
+"Utilisation de « name=racine du pisteur » :\n"
+"   \n"
+"    Ces arguments définissent la racine du pisteur à utiliser. Le nom\n"
+"    est celui utilisé pour identifier le pisteur dans l'URL (première\n"
+"    partie du chemin de l'URL). La racine du pisteur est le répertoire\n"
+"    qui a été identifié quand vous exécutez « roundup-admin list ». Il\n"
+"    est possible de fournir autant de paires « name=racine » que\n"
+"    souhaité. Assurez-vous que « name » ne contienne pas de caractères\n"
+"    inappropriés pour une URL, comme les espaces qui perturbe IE.\n"
+
+#: ../roundup/scripts/roundup_server.py:723
+msgid "Instances must be name=home"
+msgstr "Les instances doivent être nom=base-du-pisteur"
+
+#: ../roundup/scripts/roundup_server.py:737
+#, python-format
+msgid "Configuration saved to %s"
+msgstr "Configuration sauvegardée dans %s"
+
+#: ../roundup/scripts/roundup_server.py:755
+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:767
+#, 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 "Modification des collisions pour ${class} - ${tracker}"
+
+#: ../templates/classic/html/_generic.collision.html:7
+#: ../templates/minimal/html/_generic.collision.html:7
+msgid "${class} Edit Collision"
+msgstr "Modification 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;\n"
+"  jour ce noeud pendant que vous étiez en train de la\n"
+"  modifier. Veuillez <a href='${context}'>actualiser</a> ce noeud et\n"
+"  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 à propos de « ${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:53
+#: ../templates/classic/html/issue.index.html:81
+#: ../templates/minimal/html/_generic.help.html:53
+msgid "${start}..${end} out of ${total}"
+msgstr "${start}..${end} sur ${total}"
+
+#: ../templates/classic/html/_generic.help.html:57
+#: ../templates/classic/html/issue.index.html:84
+#: ../templates/minimal/html/_generic.help.html:57
+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 "Modification 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 "Modification 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 modifier 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 eux-m&ecirc;mes doivent &ecirc;tre ins&eacute;r&eacute;s en les doublant (\"\").</p><p class=\"form-help\">Les propri&eacute;t&eacute;s des liens multiples doivent s&eacute;parer leurs 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 anomalies - ${tracker}"
+
+#: ../templates/classic/html/issue.index.html:11
+msgid "List of issues"
+msgstr "Liste des anomalies"
+
+#: ../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 "Affect&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 "Regrouper par :"
+
+#: ../templates/classic/html/issue.index.html:139
+msgid "Redisplay"
+msgstr "Actualiser"
+
+#: ../templates/classic/html/issue.item.html:7
+msgid "Issue ${id}: ${title} - ${tracker}"
+msgstr "Anomalie ${id} : ${title} - ${tracker}"
+
+#: ../templates/classic/html/issue.item.html:10
+msgid "New Issue - ${tracker}"
+msgstr "Nouvelle anomalie - ${tracker}"
+
+#: ../templates/classic/html/issue.item.html:14
+msgid "New Issue"
+msgstr "Nouvelle anomalie"
+
+#: ../templates/classic/html/issue.item.html:16
+msgid "New Issue Editing"
+msgstr "Création d'une nouvelle anomalie"
+
+#: ../templates/classic/html/issue.item.html:19
+msgid "Issue${id}"
+msgstr "Anomalie ${id}"
+
+#: ../templates/classic/html/issue.item.html:22
+msgid "Issue${id} Editing"
+msgstr "Modification de l'anomalie ${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 "Affect&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 "Supprimer"
+
+#: ../templates/classic/html/issue.item.html:151
+#: ../templates/classic/html/issue.item.html:172
+#: ../templates/classic/html/query.edit.html:50
+msgid "remove"
+msgstr "supprimer"
+
+#: ../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 l'anomalie - ${tracker}"
+
+#: ../templates/classic/html/issue.search.html:4
+msgid "Issue searching"
+msgstr "Recherche de l'anomalie"
+
+#: ../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 "Affect&eacute; &agrave; :"
+
+#: ../templates/classic/html/issue.search.html:146
+msgid "assigned to me"
+msgstr "affect&eacute; &agrave; moi"
+
+#: ../templates/classic/html/issue.search.html:148
+msgid "unassigned"
+msgstr "non affect&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 "Groupe 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 l'anomalie"
+
+#: ../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 attribuez un nom, la requ&ecirc;te sera enregistr&eacute;e et disponible comme lien dans la barre lat&eacute;rale"
+
+#: ../templates/classic/html/keyword.item.html:3
+msgid "Keyword editing - ${tracker}"
+msgstr "Modification de mots cl&eacute; - ${tracker}"
+
+#: ../templates/classic/html/keyword.item.html:5
+msgid "Keyword editing"
+msgstr "Modification de mot-cl&eacute;s"
+
+#: ../templates/classic/html/keyword.item.html:11
+msgid "Existing Keywords"
+msgstr "Mot-cl&eacute;s 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;, saisissez-le ci-dessous et cliquer sur « 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 "Modification 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 "Modification du 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 "Anomalies"
+
+#: ../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 "Afficher les non-affectées"
+
+#: ../templates/classic/html/page.html:58
+msgid "Show All"
+msgstr "Tout afficher"
+
+#: ../templates/classic/html/page.html:61
+msgid "Show issue:"
+msgstr "Voir l'anomalie :"
+
+#: ../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"
+
+#: ../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'inscrire"
+
+#: ../templates/classic/html/page.html:111
+msgid "Lost&nbsp;your&nbsp;login?"
+msgstr "Retrouver&nbsp;votre&nbsp;identifiant"
+
+#: ../templates/classic/html/page.html:116
+msgid "Hello, ${user}"
+msgstr "Bienvenue, ${user}"
+
+#: ../templates/classic/html/page.html:118
+msgid "Your Issues"
+msgstr "Vos anomalies"
+
+#: ../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 "Modification de « Vos requ&ecirc;tes » - ${tracker}"
+
+#: ../templates/classic/html/query.edit.html:6
+msgid "\"Your Queries\" Editing"
+msgstr "Modification 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 de modifier 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 retir&eacute;e]"
+
+#: ../templates/classic/html/query.edit.html:67
+#: ../templates/classic/html/query.edit.html:92
+msgid "edit"
+msgstr "modifier"
+
+#: ../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 "Supprimer"
+
+#: ../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 "Enregistrer 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 électronique avec laquelle vous vous &ecirc;tes enregistr&eacute;, saisissez l&agrave; ci-dessous."
+
+#: ../templates/classic/html/user.forgotten.html:16
+msgid "Email Address:"
+msgstr "Adresse électronique :"
+
+#: ../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, saisissez-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; - suivez\n"
+"les instructions qu'il contient pour terminer le processus de\n"
+"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 électronique"
+
+#: ../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 "Retirer"
+
+#: ../templates/classic/html/user.index.html:32
+msgid "retire"
+msgstr "retirer"
+
+#: ../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 "Création d'un nouvel utilisateur"
+
+#: ../templates/classic/html/user.item.html:19
+#: ../templates/minimal/html/user.item.html:19
+msgid "User${id}"
+msgstr "Utilisateur ${id}"
+
+#: ../templates/classic/html/user.item.html:22
+#: ../templates/minimal/html/user.item.html:22
+msgid "User${id} Editing"
+msgstr "Modification de l'utilisateur ${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, saisissez 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 électronique"
+
+#: ../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 électronique 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 à ${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, suivez le lien indiqu&eacute; dans le courriel."
+
+#: ../templates/minimal/html/home.html:2
+msgid "Tracker home - ${tracker}"
+msgstr "Accueil de Tracker - ${tracker}"
+
+#: ../templates/minimal/html/home.html:4
+msgid "Tracker home"
+msgstr "Accueil de Tracker"
+
+#: ../templates/minimal/html/home.html:16
+msgid "Please select from one of the menu options on the left."
+msgstr "S&eacute;lectionnez l'une des options du menu de 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}"
+
+                
diff --git a/locale/hu.po b/locale/hu.po
new file mode 100644 (file)
index 0000000..fa82431
--- /dev/null
@@ -0,0 +1,3227 @@
+# Translation of roundup.po to Hungarian
+# Copyright © 2007 Free Software Foundation, Inc.
+# This file is distributed under the same license as the Roundup package.
+#
+# Gulácsi Tamás <T.Gulacsi@unosoft.hu>, 2006.
+# kilo aka Gabor Kmetyko <kg_kilo@freemail.hu>, 2007.
+msgid ""
+msgstr ""
+"Project-Id-Version: Roundup 1.3.3\n"
+"Report-Msgid-Bugs-To: roundup-devel@lists.sourceforge.net\n"
+"POT-Creation-Date: 2007-09-27 11:18+0300\n"
+"PO-Revision-Date: 2007-09-20 12:30+0200\n"
+"Last-Translator: kilo aka Gabor Kmetyko <kg_kilo@freemail.hu>\n"
+"Language-Team: Hungarian\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-Generator: KBabel 1.11.4\n"
+
+# ../roundup/admin.py:85 :981 :1030 :1052
+#: ../roundup/admin.py:86 ../roundup/admin.py:989 ../roundup/admin.py:1040
+#: ../roundup/admin.py:1063
+#, python-format
+msgid "no such class \"%(classname)s\""
+msgstr "nincs \"%(classname)s\" osztály"
+
+# ../roundup/admin.py:95 :99
+#: ../roundup/admin.py:96 ../roundup/admin.py:100
+#, python-format
+msgid "argument \"%(arg)s\" not propname=value"
+msgstr "A(z) \"%(arg)s\" argumentum nem név=érték alakú"
+
+#: ../roundup/admin.py:113
+#, python-format
+msgid ""
+"Problem: %(message)s\n"
+"\n"
+msgstr "Probléma: %(message)s\n"
+
+#: ../roundup/admin.py:114
+#, 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"
+" -V                -- be verbose when importing\n"
+" -v                -- report Roundup and Python versions (and quit)\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)sHasználat: roundup-admin [opciók] [<parancs><argumentumok>]\n"
+"\n"
+"Opciók:\n"
+" -i példány elérési út  -- add meg az adminisztrálni kívánt hibakövető "
+"\"könyvtárát\"\n"
+" -u                -- a parancsoknál használt felhasználónév[:jelszó]\n"
+" -d                -- írd ki a teljes nevet, ne csak az osztály azonosítót\n"
+" -c                -- adatlistáknál vesszővel válaszd el az elemeket.\n"
+"                      Ugyanaz mint '-S \",\"'.\n"
+" -S <szöveg>       -- adatlistáknál a megadott szöveggel válaszd el az "
+"elemeket\n"
+" -s                -- when outputting lists of data, space-separate them.\n"
+"                      Same as '-S \" \"'.\n"
+" -V                -- importálásnál legyél bőbeszédű\n"
+" -v                -- írd ki a Roundup és a Python verziószámokat (és lépj "
+"ki)\n"
+"\n"
+" -s, -c vagy -S közül egyszerre csak egy adható meg.\n"
+"\n"
+"segítség:\n"
+" roundup-admin -h\n"
+" roundup-admin help                       -- ez a segítség\n"
+" roundup-admin help <command>             -- parancs-specifikus segítség\n"
+" roundup-admin help all                   -- minden elérhető segítség\n"
+
+#: ../roundup/admin.py:141
+msgid "Commands:"
+msgstr "Parancsok:"
+
+#: ../roundup/admin.py:148
+msgid ""
+"Commands may be abbreviated as long as the abbreviation\n"
+"matches only one command, e.g. l == li == lis == list."
+msgstr ""
+"A parancsok rövidíthetők mindaddig, amíg csak egy parancsra illenek, pl. l "
+"== li == lis == list."
+
+#: ../roundup/admin.py:178
+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:241
+#, python-format
+msgid "%s:"
+msgstr "%s:"
+
+#: ../roundup/admin.py:246
+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 ""
+"Használat: help téma\n"
+"        Segítséget ad a témáról.\n"
+"\n"
+"        commands  -- parancsok listája\n"
+"        <command> -- segítség adott parancshoz\n"
+"        initopts  -- kezdő parancs opciók\n"
+"        all       -- minden elérhető segítség\n"
+"        "
+
+#: ../roundup/admin.py:269
+#, python-format
+msgid "Sorry, no help for \"%(topic)s\""
+msgstr "Elnézést, \"%(topic)s\" témához nincs súgó"
+
+# ../roundup/admin.py:340 :396
+#: ../roundup/admin.py:346 ../roundup/admin.py:402
+msgid "Templates:"
+msgstr "Sablonok:"
+
+# ../roundup/admin.py:343 :407
+#: ../roundup/admin.py:349 ../roundup/admin.py:413
+msgid "Back ends:"
+msgstr "Adatbázis hátterek:"
+
+#: ../roundup/admin.py:352
+msgid ""
+"Usage: install [template [backend [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 and backend may be specified on the command-line\n"
+"        as arguments, in that order.\n"
+"\n"
+"        Command line arguments following the backend allows you to\n"
+"        pass initial values 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:369 :466 :527 :606 :656 :714 :735 :763 :834 :901 :972
+# :1020 :1042 :1069 :1136 :1207
+#: ../roundup/admin.py:375 ../roundup/admin.py:472 ../roundup/admin.py:533
+#: ../roundup/admin.py:612 ../roundup/admin.py:663 ../roundup/admin.py:721
+#: ../roundup/admin.py:742 ../roundup/admin.py:770 ../roundup/admin.py:842
+#: ../roundup/admin.py:909 ../roundup/admin.py:980 ../roundup/admin.py:1030
+#: ../roundup/admin.py:1053 ../roundup/admin.py:1084 ../roundup/admin.py:1180
+#: ../roundup/admin.py:1253
+msgid "Not enough arguments supplied"
+msgstr "Nincs megadva elég argumentum"
+
+#: ../roundup/admin.py:381
+#, python-format
+msgid "Instance home parent directory \"%(parent)s\" does not exist"
+msgstr "Példány könyvtár szülője (\"%(parent)s\") nem létezik"
+
+#: ../roundup/admin.py:389
+#, 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 ""
+"FIGYELEM: Úgy tűnik, már létezik egy hibakövető a \"%(tracker_home)s\" "
+"könyvtárban!\n"
+"Ha újra installálod, minden adat elveszik!\n"
+"Töröljem? Y/N: "
+
+#: ../roundup/admin.py:404
+msgid "Select template [classic]: "
+msgstr "Sablon választása [classic]: "
+
+#: ../roundup/admin.py:415
+msgid "Select backend [anydbm]: "
+msgstr "Adatbázis háttér választása [anydbm]: "
+
+#: ../roundup/admin.py:425
+#, python-format
+msgid "Error in configuration settings: \"%s\""
+msgstr "Hiba a konfigurációs beállításokban: \"%s\""
+
+#: ../roundup/admin.py:434
+#, python-format
+msgid ""
+"\n"
+"---------------------------------------------------------------------------\n"
+" You should now edit the tracker configuration file:\n"
+"   %(config_file)s"
+msgstr ""
+"\n"
+"---------------------------------------------------------------------------\n"
+" Most kell szerkesztened a konfigurációs fájlt:\n"
+"   %(config_file)s"
+
+#: ../roundup/admin.py:444
+msgid " ... at a minimum, you must set following options:"
+msgstr " ... legalább a következő opciókat kell beállítani:"
+
+#: ../roundup/admin.py:449
+#, 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:467
+msgid ""
+"Usage: genconfig <filename>\n"
+"        Generate a new tracker config file (ini style) with default values\n"
+"        in <filename>.\n"
+"        "
+msgstr ""
+"Használat: genconfig <fájlnév>\n"
+"        Új hibakövető konfigurációs fájl (ini stílusú) generálása "
+"alapértelmezett értékekkel\n"
+"        a <fájlnév> fájlba.\n"
+"        "
+
+#. password
+#: ../roundup/admin.py:477
+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 ""
+"Használat: initialise [adminpw]\n"
+"        Inicializál egy új Roundup hibakövetőt.\n"
+"\n"
+"        Az adminisztrátori részletek ebben a lépésben lesznek beállítva.\n"
+"\n"
+"        Végrehajtja az adatbázist inicializáló dbinit.init() rutint\n"
+"        "
+
+#: ../roundup/admin.py:491
+msgid "Admin Password: "
+msgstr "Adminisztrátori jelszó: "
+
+#: ../roundup/admin.py:492
+msgid "       Confirm: "
+msgstr "       Megerősítés "
+
+#: ../roundup/admin.py:496
+msgid "Instance home does not exist"
+msgstr "A példány könyvtára nem létezik"
+
+#: ../roundup/admin.py:500
+msgid "Instance has not been installed"
+msgstr "A példány nem lett installálva"
+
+#: ../roundup/admin.py:505
+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 ""
+"FIGYELEM: Az adatbázis már inicializált!\n"
+"Újrainicializálás esetén minden adat elvész!\n"
+"Törli? Y/N: "
+
+#: ../roundup/admin.py:526
+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 ""
+"Használat: get property designator[,designator]*\n"
+"        Visszaadja egy vagy több jelölő tulajdonságát.\n"
+"\n"
+"        Visszaadja a jelölő által meghatározott\n"
+"        csomópont értékét.\n"
+"        "
+
+# ../roundup/admin.py:560 :575
+#: ../roundup/admin.py:566 ../roundup/admin.py:581
+#, python-format
+msgid "property %s is not of type Multilink or Link so -d flag does not apply."
+msgstr ""
+"A(z) %s tulajdonság nem Multilink vagy Link típusú, ezért a -d kapcsoló nem "
+"alkalmazható."
+
+# ../roundup/admin.py:583 :983 :1032 :1054
+#: ../roundup/admin.py:589 ../roundup/admin.py:991 ../roundup/admin.py:1042
+#: ../roundup/admin.py:1065
+#, python-format
+msgid "no such %(classname)s node \"%(nodeid)s\""
+msgstr "nincs \"%(nodeid)s\" %(classname)s csomópont"
+
+#: ../roundup/admin.py:591
+#, python-format
+msgid "no such %(classname)s property \"%(propname)s\""
+msgstr "nincs \"%(propname)s\" %(classname)s tulajdonság"
+
+#: ../roundup/admin.py:600
+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:655
+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:701 :854 :866 :920
+#: ../roundup/admin.py:708 ../roundup/admin.py:862 ../roundup/admin.py:874
+#: ../roundup/admin.py:928
+#, python-format
+msgid "%(classname)s has no property \"%(propname)s\""
+msgstr "%(classname)s-nek nincs \"%(propname)s\" tulajdonsága"
+
+#: ../roundup/admin.py:715
+msgid ""
+"Usage: specification classname\n"
+"        Show the properties for a classname.\n"
+"\n"
+"        This lists the properties for a given class.\n"
+"        "
+msgstr ""
+"Használat: specification classname\n"
+"        Osztály tulajdonságainak megjelenítése.\n"
+"\n"
+"        Listázza az adott osztály tulajdonságait.\n"
+"        "
+
+#: ../roundup/admin.py:730
+#, python-format
+msgid "%(key)s: %(value)s (key property)"
+msgstr "%(key)s: %(value)s (kulcs tulajdonság)"
+
+#: ../roundup/admin.py:732 ../roundup/admin.py:759
+#, python-format
+msgid "%(key)s: %(value)s"
+msgstr "%(key)s: %(value)s"
+
+#: ../roundup/admin.py:735
+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:762
+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:789
+#, python-format
+msgid "%(propname)s (Password): "
+msgstr "%(propname)s (Jelszó): "
+
+#: ../roundup/admin.py:791
+#, python-format
+msgid "   %(propname)s (Again): "
+msgstr "   %(propname)s (Ismét): "
+
+#: ../roundup/admin.py:793
+msgid "Sorry, try again..."
+msgstr "Sajnálom, próbálja újra..."
+
+#: ../roundup/admin.py:797
+#, python-format
+msgid "%(propname)s (%(proptype)s): "
+msgstr "%(propname)s (%(proptype)s): "
+
+#: ../roundup/admin.py:815
+#, python-format
+msgid "you must provide the \"%(propname)s\" property."
+msgstr "meg kell adni a(z) \"%(propname)s\" tulajdonságot."
+
+#: ../roundup/admin.py:827
+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:840
+msgid "Too many arguments supplied"
+msgstr "Túl sok argumentum került megadásra"
+
+#: ../roundup/admin.py:876
+#, python-format
+msgid "%(nodeid)4s: %(value)s"
+msgstr "%(nodeid)4s: %(value)s"
+
+#: ../roundup/admin.py:880
+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:924
+#, python-format
+msgid "\"%(spec)s\" not name:width"
+msgstr "\"%(spec)s\" nem név:hossz formátumú"
+
+#: ../roundup/admin.py:974
+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:995
+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:1010
+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:1023
+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:1047
+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:1070
+msgid ""
+"Usage: export [[-]class[,class]] export_dir\n"
+"        Export the database to colon-separated-value files.\n"
+"        To exclude the files (e.g. for the msg or file class),\n"
+"        use the exporttables command.\n"
+"\n"
+"        Optionally limit the export to just the named classes\n"
+"        or exclude the named classes, if the 1st argument starts with '-'.\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:1145
+msgid ""
+"Usage: exporttables [[-]class[,class]] export_dir\n"
+"        Export the database to colon-separated-value files, excluding the\n"
+"        files below $TRACKER_HOME/db/files/ (which can be archived "
+"separately).\n"
+"        To include the files, use the export command.\n"
+"\n"
+"        Optionally limit the export to just the named classes\n"
+"        or exclude the named classes, if the 1st argument starts with '-'.\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:1160
+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:1235
+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:1263
+msgid "Invalid format"
+msgstr "Hibás formátum"
+
+#: ../roundup/admin.py:1274
+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:1288
+#, python-format
+msgid "no such item \"%(designator)s\""
+msgstr "nincs ilyen elem: \"%(designator)s\""
+
+#: ../roundup/admin.py:1298
+msgid ""
+"Usage: security [Role name]\n"
+"        Display the Permissions available to one or all Roles.\n"
+"        "
+msgstr ""
+"Használat: security [szerepkör]\n"
+"        Megjeleníti a megadott vagy az összes szerepkör jogosultságait.\n"
+"        "
+
+#: ../roundup/admin.py:1306
+#, python-format
+msgid "No such Role \"%(role)s\""
+msgstr "Nincs ilyen szerepkör: \"%(role)s\""
+
+#: ../roundup/admin.py:1312
+#, python-format
+msgid "New Web users get the Roles \"%(role)s\""
+msgstr "Új web felhasználók ezeket a szerepköröket kapják: \"%(role)s\""
+
+#: ../roundup/admin.py:1314
+#, python-format
+msgid "New Web users get the Role \"%(role)s\""
+msgstr "Új web felhasználók ezt a szerepkört kapják \"%(role)s\""
+
+#: ../roundup/admin.py:1317
+#, python-format
+msgid "New Email users get the Roles \"%(role)s\""
+msgstr "Új e-mail felhasználók ezeket a szerepköröket kapják: \"%(role)s\""
+
+#: ../roundup/admin.py:1319
+#, python-format
+msgid "New Email users get the Role \"%(role)s\""
+msgstr "Új e-mail felhasználók ezt a szerepkört kapják: \"%(role)s\""
+
+#: ../roundup/admin.py:1322
+#, python-format
+msgid "Role \"%(name)s\":"
+msgstr "\"%(name)s\" szerepkör:"
+
+#: ../roundup/admin.py:1327
+#, python-format
+msgid " %(description)s (%(name)s for \"%(klass)s\": %(properties)s only)"
+msgstr ""
+
+#: ../roundup/admin.py:1330
+#, python-format
+msgid " %(description)s (%(name)s for \"%(klass)s\" only)"
+msgstr ""
+
+#: ../roundup/admin.py:1333
+#, python-format
+msgid " %(description)s (%(name)s)"
+msgstr " %(description)s (%(name)s)"
+
+#: ../roundup/admin.py:1362
+#, python-format
+msgid "Unknown command \"%(command)s\" (\"help commands\" for a list)"
+msgstr ""
+"\"%(command)s\": ismeretlen parancs (\"help commands\" parancsok "
+"listázásához)"
+
+#: ../roundup/admin.py:1368
+#, python-format
+msgid "Multiple commands match \"%(command)s\": %(list)s"
+msgstr ""
+"Több parancs is illeszkedik a megadott \"%(command)s\" parancsra: %(list)s"
+
+#: ../roundup/admin.py:1375
+msgid "Enter tracker home: "
+msgstr "Adja meg a hibakövető könyvtárát: "
+
+# ../roundup/admin.py:1335 :1341 :1361
+#: ../roundup/admin.py:1382 ../roundup/admin.py:1388 ../roundup/admin.py:1408
+#, python-format
+msgid "Error: %(message)s"
+msgstr "Hiba: %(message)s"
+
+#: ../roundup/admin.py:1396
+#, python-format
+msgid "Error: Couldn't open tracker: %(message)s"
+msgstr "Hiba: Hibakövető megnyitása sikertelen: %(message)s"
+
+#: ../roundup/admin.py:1421
+#, python-format
+msgid ""
+"Roundup %s ready for input.\n"
+"Type \"help\" for help."
+msgstr ""
+"A Roundup %s fogadókész.\n"
+"Segítségért gépeljen \"help\"-et."
+
+#: ../roundup/admin.py:1426
+msgid "Note: command history and editing not available"
+msgstr "Megjegyzés: a parancsok története és szerkesztése nem elérhető"
+
+#: ../roundup/admin.py:1430
+msgid "roundup> "
+msgstr "roundup> "
+
+#: ../roundup/admin.py:1432
+msgid "exit..."
+msgstr "kilépés..."
+
+#: ../roundup/admin.py:1442
+msgid "There are unsaved changes. Commit them (y/N)? "
+msgstr "Vannak nem mentett változtatások. Elmenti őket (y/N)? "
+
+#: ../roundup/backends/back_anydbm.py:219
+#: ../roundup/backends/sessions_dbm.py:50
+msgid "Couldn't identify database type"
+msgstr ""
+
+#: ../roundup/backends/back_anydbm.py:245
+#, python-format
+msgid "Couldn't open database - the required module '%s' is not available"
+msgstr ""
+
+# ../roundup/backends/back_anydbm.py:795:1070
+# ../roundup/backends/back_metakit.py:567:834
+# ../roundup/backends/rdbms_common.py:1320:1549 :1267:1285 :1331:1901
+# :1755:1775 :1828:2436 :866:1601
+#: ../roundup/backends/back_anydbm.py:795
+#: ../roundup/backends/back_anydbm.py:1070
+#: ../roundup/backends/back_anydbm.py:1267
+#: ../roundup/backends/back_anydbm.py:1285
+#: ../roundup/backends/back_anydbm.py:1331
+#: ../roundup/backends/back_anydbm.py:1901
+#: ../roundup/backends/back_metakit.py:567
+#: ../roundup/backends/back_metakit.py:834
+#: ../roundup/backends/back_metakit.py:866
+#: ../roundup/backends/back_metakit.py:1601
+#: ../roundup/backends/rdbms_common.py:1320
+#: ../roundup/backends/rdbms_common.py:1549
+#: ../roundup/backends/rdbms_common.py:1755
+#: ../roundup/backends/rdbms_common.py:1775
+#: ../roundup/backends/rdbms_common.py:1828
+#: ../roundup/backends/rdbms_common.py:2436
+msgid "Database open read-only"
+msgstr ""
+
+#: ../roundup/backends/back_anydbm.py:2003
+#, python-format
+msgid "WARNING: invalid date tuple %r"
+msgstr "FIGYELEM: hibás dátum tuple %r"
+
+#: ../roundup/backends/rdbms_common.py:1449
+msgid "create"
+msgstr "létrehozás"
+
+#: ../roundup/backends/rdbms_common.py:1615
+msgid "unlink"
+msgstr "törlés"
+
+#: ../roundup/backends/rdbms_common.py:1619
+msgid "link"
+msgstr "kapcsolás"
+
+#: ../roundup/backends/rdbms_common.py:1741
+msgid "set"
+msgstr "beállítás"
+
+#: ../roundup/backends/rdbms_common.py:1765
+msgid "retired"
+msgstr "visszavonult"
+
+#: ../roundup/backends/rdbms_common.py:1795
+msgid "restored"
+msgstr "visszaállított"
+
+#: ../roundup/cgi/actions.py:58
+#, python-format
+msgid "You do not have permission to %(action)s the %(classname)s class."
+msgstr "Nincs jogosultsága %(action)s műveletre a(z) %(classname)s osztályon."
+
+#: ../roundup/cgi/actions.py:89
+msgid "No type specified"
+msgstr "Típus nincs megadva"
+
+#: ../roundup/cgi/actions.py:91
+msgid "No ID entered"
+msgstr "Azonosító nincs megadva"
+
+#: ../roundup/cgi/actions.py:97
+#, python-format
+msgid "\"%(input)s\" is not an ID (%(classname)s ID required)"
+msgstr "\"%(input)s\" nem azonosító (%(classname)s azonosító szükséges)"
+
+#: ../roundup/cgi/actions.py:117
+msgid "You may not retire the admin or anonymous user"
+msgstr "Az admin és anonymous felhasználókat nem lehet visszavonultatni"
+
+#: ../roundup/cgi/actions.py:124
+#, python-format
+msgid "%(classname)s %(itemid)s has been retired"
+msgstr "%(classname)s %(itemid)s visszavonásra került"
+
+# ../roundup/cgi/actions.py:174 :202
+#: ../roundup/cgi/actions.py:169 ../roundup/cgi/actions.py:197
+msgid "You do not have permission to edit queries"
+msgstr "Nincs jogosultsága a lekérdezések szerkesztéséhez"
+
+# ../roundup/cgi/actions.py:180 :209
+#: ../roundup/cgi/actions.py:175 ../roundup/cgi/actions.py:204
+msgid "You do not have permission to store queries"
+msgstr "Nincs jogosultsága a lekérdezések tárolásához"
+
+#: ../roundup/cgi/actions.py:310
+#, python-format
+msgid "Not enough values on line %(line)s"
+msgstr "Nincs elég érték a(z) %(line)s soron"
+
+#: ../roundup/cgi/actions.py:357
+msgid "Items edited OK"
+msgstr "Az elemek sikeresen szerkesztve"
+
+#: ../roundup/cgi/actions.py:416
+#, python-format
+msgid "%(class)s %(id)s %(properties)s edited ok"
+msgstr "%(class)s %(id)s %(properties)s sikeresen szerkesztve"
+
+#: ../roundup/cgi/actions.py:419
+#, python-format
+msgid "%(class)s %(id)s - nothing changed"
+msgstr "%(class)s %(id)s - nincs változás"
+
+#: ../roundup/cgi/actions.py:431
+#, python-format
+msgid "%(class)s %(id)s created"
+msgstr "%(class)s %(id)s létrehozva"
+
+#: ../roundup/cgi/actions.py:463
+#, python-format
+msgid "You do not have permission to edit %(class)s"
+msgstr "Nincs jogosultsága szerkeszteni %(class)s-t"
+
+#: ../roundup/cgi/actions.py:475
+#, python-format
+msgid "You do not have permission to create %(class)s"
+msgstr "Nincs jogosultsága létrehozni %(class)s-t"
+
+#: ../roundup/cgi/actions.py:499
+msgid "You do not have permission to edit user roles"
+msgstr "Nincs jogosultsága a felhasználói szerepkörök szerkesztéséhez"
+
+#: ../roundup/cgi/actions.py:549
+#, 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 ""
+"Szerkesztési hiba: valaki már szerkesztette %s (%s). Nézze meg a <a target="
+"\"new\" href=\"%s%s\">változtatásait</a> egy új ablakban."
+
+#: ../roundup/cgi/actions.py:577
+#, python-format
+msgid "Edit Error: %s"
+msgstr "Szerkesztési hiba: %s"
+
+# ../roundup/cgi/actions.py:596 :607 :778 :797
+#: ../roundup/cgi/actions.py:608 ../roundup/cgi/actions.py:619
+#: ../roundup/cgi/actions.py:790 ../roundup/cgi/actions.py:809
+#, python-format
+msgid "Error: %s"
+msgstr "Hiba: %s"
+
+#: ../roundup/cgi/actions.py:645
+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:687
+#, python-format
+msgid "Password reset and email sent to %s"
+msgstr "A jelszó törlésre került és e-mailt küldtünk %s-nek"
+
+#: ../roundup/cgi/actions.py:696
+msgid "Unknown username"
+msgstr "Ismeretlen felhasználónév"
+
+#: ../roundup/cgi/actions.py:704
+msgid "Unknown email address"
+msgstr "Ismeretlen e-mail cím"
+
+#: ../roundup/cgi/actions.py:709
+msgid "You need to specify a username or address"
+msgstr "Meg kell adni egy felhasználónevet vagy címet"
+
+#: ../roundup/cgi/actions.py:734
+#, python-format
+msgid "Email sent to %s"
+msgstr "E-mail elküldve %s-nek"
+
+#: ../roundup/cgi/actions.py:753
+msgid "You are now registered, welcome!"
+msgstr "Regisztrálás sikeres, isten hozott!"
+
+#: ../roundup/cgi/actions.py:798
+msgid "It is not permitted to supply roles at registration."
+msgstr "Regisztráláskor nem adhatók meg szerepkörök."
+
+#: ../roundup/cgi/actions.py:890
+msgid "You are logged out"
+msgstr "Kijelentkezett"
+
+#: ../roundup/cgi/actions.py:907
+msgid "Username required"
+msgstr "A felhasználónév szükséges"
+
+# ../roundup/cgi/actions.py:930 :934
+#: ../roundup/cgi/actions.py:942 ../roundup/cgi/actions.py:946
+msgid "Invalid login"
+msgstr "Hibás bejelentkezés"
+
+#: ../roundup/cgi/actions.py:952
+msgid "You do not have permission to login"
+msgstr "Nincs jogosultsága bejelentkezni"
+
+#: ../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>Sablon Hiba</h1>\n"
+"<p><b>%(exc_type)s</b>: %(exc_value)s</p>\n"
+"<p class=\"help\">Debug információk alább</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>\"%(name)s\" keresése, aktuális elérési út:<ol>%(path)s</ol></li>"
+
+#: ../roundup/cgi/cgitb.py:71
+#, python-format
+msgid "<li>In %s</li>"
+msgstr "<li>%s-ban</li>"
+
+#: ../roundup/cgi/cgitb.py:76
+#, python-format
+msgid "A problem occurred in your template \"%s\"."
+msgstr "Probléma merült fel a(z) \"%s\" sablonnal."
+
+#: ../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 "Teljes visszakövetés:"
+
+#: ../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>Probléma merült fel egy Python parancsfájl futtatása során. Alább "
+"megtekinthető a hibához vezető függvényhívások sora, a legutóbbi (legbelső) "
+"hívás látható legelőször. A kivétel tulajdonságai:"
+
+#: ../roundup/cgi/cgitb.py:129
+msgid "&lt;file is None - probably inside <tt>eval</tt> or <tt>exec</tt>&gt;"
+msgstr ""
+"&lt;A fájl None értékű - feltehetőleg <tt>eval</tt> vagy <tt>exec</tt> "
+"utasításon belül&gt;"
+
+#: ../roundup/cgi/cgitb.py:138
+#, python-format
+msgid "in <strong>%s</strong>"
+msgstr "<strong>%s</strong>-ban"
+
+# ../roundup/cgi/cgitb.py:172 :178
+#: ../roundup/cgi/cgitb.py:172 ../roundup/cgi/cgitb.py:178
+msgid "<em>undefined</em>"
+msgstr "<em>nem definiált</em>"
+
+#: ../roundup/cgi/client.py:51
+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>Hiba történt</title></head>\n"
+"<body><h1>Hiba történt</h1>\n"
+"<p>Probléma merült fel a kérés feldolgozása közben.\n"
+"A hibakövető karbantartói értesítést kaptak a problémáról.</p>\n"
+"</body></html>"
+
+#: ../roundup/cgi/client.py:377
+msgid "Form Error: "
+msgstr "Űrlap hiba: "
+
+#: ../roundup/cgi/client.py:432
+#, python-format
+msgid "Unrecognized charset: %r"
+msgstr "Ismeretlen karakterkészlet: %r"
+
+#: ../roundup/cgi/client.py:560
+msgid "Anonymous users are not allowed to use the web interface"
+msgstr "Anonim felhasználók nem használhatják a webes felületet"
+
+#: ../roundup/cgi/client.py:715
+msgid "You are not allowed to view this file."
+msgstr "Nem nézheti meg ezt a fájlt."
+
+#: ../roundup/cgi/client.py:808
+#, python-format
+msgid "%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n"
+msgstr "%(starttag)sEltelt idő: %(seconds)fs%(endtag)s\n"
+
+#: ../roundup/cgi/client.py:812
+#, 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 találatok: %(cache_hits)d, tévedés %(cache_misses)d. "
+"Elemek betöltése: %(get_items)f mp. Szűrés: %(filtering)f mp.%(endtag)s\n"
+
+#: ../roundup/cgi/form_parser.py:283
+#, fuzzy, python-format
+msgid "link \"%(key)s\" value \"%(entry)s\" not a designator"
+msgstr "A(z) \"%(value)s\" értékű \"%(key)s\" csatolás nem teljes név"
+
+#: ../roundup/cgi/form_parser.py:301
+#, python-format
+msgid "%(class)s %(property)s is not a link or multilink property"
+msgstr "A(y) %(class)s %(property)s nem link vagy multilink típusú tulajdonság"
+
+#: ../roundup/cgi/form_parser.py:313
+#, fuzzy, python-format
+msgid ""
+"The form action claims to require property \"%(property)s\" which doesn't "
+"exist"
+msgstr ""
+"%(action)s műveletet kíván a \"%(property)s\" tulajdonságon végezni, de az "
+"nem létezik"
+
+#: ../roundup/cgi/form_parser.py:335
+#, python-format
+msgid ""
+"You have submitted a %(action)s action for the property \"%(property)s\" "
+"which doesn't exist"
+msgstr ""
+"%(action)s műveletet kíván a \"%(property)s\" tulajdonságon végezni, de az "
+"nem létezik"
+
+# ../roundup/cgi/form_parser.py:331 :357
+#: ../roundup/cgi/form_parser.py:354 ../roundup/cgi/form_parser.py:380
+#, python-format
+msgid "You have submitted more than one value for the %s property"
+msgstr "Egynél több értéket adott meg a(z) %s tulajdonsághoz"
+
+# ../roundup/cgi/form_parser.py:354 :360
+#: ../roundup/cgi/form_parser.py:377 ../roundup/cgi/form_parser.py:383
+msgid "Password and confirmation text do not match"
+msgstr "A jelszó és a megerősítés nem egyezik"
+
+#: ../roundup/cgi/form_parser.py:418
+#, python-format
+msgid "property \"%(propname)s\": \"%(value)s\" not currently in list"
+msgstr "\"%(propname)s\" tulajdonság: \"%(value)s\" jelenleg nincs a listában"
+
+#: ../roundup/cgi/form_parser.py:551
+#, python-format
+msgid "Required %(class)s property %(property)s not supplied"
+msgid_plural "Required %(class)s properties %(property)s not supplied"
+msgstr[0] "Nincs megadva a(z) %(class)s kötelező %(property)s tulajdonsága"
+msgstr[1] ""
+"Nincsenek megadva a(z) %(class)s kötelező %(property)s tulajdonságai"
+
+#: ../roundup/cgi/form_parser.py:574
+msgid "File is empty"
+msgstr "A fájl üres"
+
+#: ../roundup/cgi/templating.py:77
+#, python-format
+msgid "You are not allowed to %(action)s items of class %(class)s"
+msgstr ""
+"Nincs jogosultsága a(z) %(class)s osztály elemein %(action)s műveletet "
+"végrehajtani"
+
+#: ../roundup/cgi/templating.py:657
+msgid "(list)"
+msgstr "(lista)"
+
+#: ../roundup/cgi/templating.py:726
+msgid "Submit New Entry"
+msgstr "Létrehozás"
+
+# ../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:740 ../roundup/cgi/templating.py:873
+#: ../roundup/cgi/templating.py:1294 ../roundup/cgi/templating.py:1323
+#: ../roundup/cgi/templating.py:1343 ../roundup/cgi/templating.py:1356
+#: ../roundup/cgi/templating.py:1407 ../roundup/cgi/templating.py:1430
+#: ../roundup/cgi/templating.py:1466 ../roundup/cgi/templating.py:1503
+#: ../roundup/cgi/templating.py:1556 ../roundup/cgi/templating.py:1573
+#: ../roundup/cgi/templating.py:1657 ../roundup/cgi/templating.py:1677
+#: ../roundup/cgi/templating.py:1695 ../roundup/cgi/templating.py:1727
+#: ../roundup/cgi/templating.py:1737 ../roundup/cgi/templating.py:1789
+#: ../roundup/cgi/templating.py:1978
+msgid "[hidden]"
+msgstr "[rejtett]"
+
+#: ../roundup/cgi/templating.py:741
+msgid "New node - no history"
+msgstr "Új bejegyzés - nincs történet"
+
+#: ../roundup/cgi/templating.py:855
+msgid "Submit Changes"
+msgstr "Változások mentése"
+
+#: ../roundup/cgi/templating.py:937
+msgid "<em>The indicated property no longer exists</em>"
+msgstr "<em>A jelzett tulajdonság már nem létezik</em>"
+
+#: ../roundup/cgi/templating.py:938
+#, python-format
+msgid "<em>%s: %s</em>\n"
+msgstr "<em>%s: %s</em>\n"
+
+#: ../roundup/cgi/templating.py:951
+#, python-format
+msgid "The linked class %(classname)s no longer exists"
+msgstr "A csatolt %(classname)s osztály már nem létezik"
+
+# ../roundup/cgi/templating.py:940 :964
+#: ../roundup/cgi/templating.py:984 ../roundup/cgi/templating.py:1008
+msgid "<strike>The linked node no longer exists</strike>"
+msgstr "<strike>A csatolt bejegyzés már nem létezik</strike>"
+
+#: ../roundup/cgi/templating.py:1061
+#, python-format
+msgid "%s: (no value)"
+msgstr "%s: (nincs érték)"
+
+#: ../roundup/cgi/templating.py:1073
+msgid ""
+"<strong><em>This event is not handled by the history display!</em></strong>"
+msgstr ""
+"<strong><em>Az előzmények képernyő nem kezeli ezt az eseményt!</em></strong>"
+
+#: ../roundup/cgi/templating.py:1085
+msgid "<tr><td colspan=4><strong>Note:</strong></td></tr>"
+msgstr "<tr><td colspan=4><strong>Megjegyzés:</strong></td></tr>"
+
+#: ../roundup/cgi/templating.py:1094
+msgid "History"
+msgstr "Előzmények"
+
+#: ../roundup/cgi/templating.py:1096
+msgid "<th>Date</th>"
+msgstr "<th>Dátum</th>"
+
+#: ../roundup/cgi/templating.py:1097
+msgid "<th>User</th>"
+msgstr "<th>Szerző</th>"
+
+#: ../roundup/cgi/templating.py:1098
+msgid "<th>Action</th>"
+msgstr "<th>Művelet</th>"
+
+#: ../roundup/cgi/templating.py:1099
+msgid "<th>Args</th>"
+msgstr "<th>Tulajdonságok</th>"
+
+#: ../roundup/cgi/templating.py:1141
+#, python-format
+msgid "Copy of %(class)s %(id)s"
+msgstr "A(z) %(class)s %(id)s másolata"
+
+#: ../roundup/cgi/templating.py:1434
+msgid "*encrypted*"
+msgstr "*titkosítva*"
+
+# ../roundup/cgi/templating.py:1006 :1404 :1425 :1431
+#: ../roundup/cgi/templating.py:1507 ../roundup/cgi/templating.py:1528
+#: ../roundup/cgi/templating.py:1534 ../roundup/cgi/templating.py:1050
+msgid "No"
+msgstr "Nem"
+
+# ../roundup/cgi/templating.py:1006 :1404 :1423 :1428
+#: ../roundup/cgi/templating.py:1507 ../roundup/cgi/templating.py:1526
+#: ../roundup/cgi/templating.py:1531 ../roundup/cgi/templating.py:1050
+msgid "Yes"
+msgstr "Igen"
+
+#: ../roundup/cgi/templating.py:1620
+msgid ""
+"default value for DateHTMLProperty must be either DateHTMLProperty or string "
+"date representation."
+msgstr ""
+"a DateHTMLProperty alapértéke DateHTMLProperty vagy szöveges dátumleírás "
+"típusú kell legyen."
+
+#: ../roundup/cgi/templating.py:1780
+#, python-format
+msgid "Attempt to look up %(attr)s on a missing value"
+msgstr "Kísérlet %(attr)s keresésére egy hiányzó értéken"
+
+#: ../roundup/cgi/templating.py:1853
+#, python-format
+msgid "<option %svalue=\"-1\">- no selection -</option>"
+msgstr "<option %svalue=\"-1\">- nincs kiválasztás -</option>"
+
+#: ../roundup/date.py:300
+msgid ""
+"Not a date spec: \"yyyy-mm-dd\", \"mm-dd\", \"HH:MM\", \"HH:MM:SS\" or "
+"\"yyyy-mm-dd.HH:MM:SS.SSS\""
+msgstr ""
+"Nem dátum specifikáció: \"éééé-hh-nn\", \"hh-nn\", \"ÓÓ:PP\", \"ÓÓ:PP:SS\" "
+"vagy \"éééé-hh-nn.ÓÓ:PP:SS.SSS\""
+
+#: ../roundup/date.py:359
+#, 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 nem dátum / idő specifikáció \"éééé-hh-nn\", \"hh-nn\", \"ÓÓ:PP\", \"ÓÓ:"
+"PP:SS\" vagy \"éééé-hh-nn.ÓÓ:PP:SS.SSS\""
+
+#: ../roundup/date.py:666
+msgid ""
+"Not an interval spec: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS] [date spec]"
+msgstr ""
+"Nem időköz specifikáció: [+-] [#é] [#h] [#w] [#n] [[[Ó]Ó:PP]:SS] [dátum "
+"típus]"
+
+#: ../roundup/date.py:685
+msgid "Not an interval spec: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS]"
+msgstr "Nem időköz specifikáció: [+-] [#é] [#h] [#w] [#n] [[[Ó]Ó:PP]:SS]"
+
+#: ../roundup/date.py:822
+#, python-format
+msgid "%(number)s year"
+msgid_plural "%(number)s years"
+msgstr[0] "%(number)s éve"
+msgstr[1] "%(number)s éve"
+
+#: ../roundup/date.py:826
+#, python-format
+msgid "%(number)s month"
+msgid_plural "%(number)s months"
+msgstr[0] "%(number)s hónapja"
+msgstr[1] "%(number)s hónapja"
+
+#: ../roundup/date.py:830
+#, python-format
+msgid "%(number)s week"
+msgid_plural "%(number)s weeks"
+msgstr[0] "%(number)s hete"
+msgstr[1] "%(number)s hete"
+
+#: ../roundup/date.py:834
+#, python-format
+msgid "%(number)s day"
+msgid_plural "%(number)s days"
+msgstr[0] "%(number)s napja"
+msgstr[1] "%(number)s napja"
+
+#: ../roundup/date.py:838
+msgid "tomorrow"
+msgstr "holnap"
+
+#: ../roundup/date.py:840
+msgid "yesterday"
+msgstr "tegnap"
+
+#: ../roundup/date.py:843
+#, python-format
+msgid "%(number)s hour"
+msgid_plural "%(number)s hours"
+msgstr[0] "%(number)s órája"
+msgstr[1] "%(number)s órája"
+
+#: ../roundup/date.py:847
+msgid "an hour"
+msgstr "egy órája"
+
+#: ../roundup/date.py:849
+msgid "1 1/2 hours"
+msgstr "1 1/2 órája"
+
+#: ../roundup/date.py:851
+#, python-format
+msgid "1 %(number)s/4 hours"
+msgid_plural "1 %(number)s/4 hours"
+msgstr[0] "1 %(number)s/4 órája"
+msgstr[1] "1 %(number)s/4 órája"
+
+#: ../roundup/date.py:855
+msgid "in a moment"
+msgstr "egy pillanat"
+
+#: ../roundup/date.py:857
+msgid "just now"
+msgstr "épp most"
+
+#: ../roundup/date.py:860
+msgid "1 minute"
+msgstr "1 perce"
+
+#: ../roundup/date.py:863
+#, python-format
+msgid "%(number)s minute"
+msgid_plural "%(number)s minutes"
+msgstr[0] "%(number)s perce"
+msgstr[1] "%(number)s perce"
+
+#: ../roundup/date.py:866
+msgid "1/2 an hour"
+msgstr "1/2 órája"
+
+#: ../roundup/date.py:868
+#, python-format
+msgid "%(number)s/4 hour"
+msgid_plural "%(number)s/4 hours"
+msgstr[0] "%(number)s/4 órája"
+msgstr[1] "%(number)s/4 órája"
+
+#: ../roundup/date.py:872
+#, python-format
+msgid "%s ago"
+msgstr "%s"
+
+#: ../roundup/date.py:874
+#, python-format
+msgid "in %s"
+msgstr "%s-ban"
+
+#: ../roundup/hyperdb.py:87
+#, fuzzy, python-format
+msgid "property %s: %s"
+msgstr "Hiba: %s: %s"
+
+#: ../roundup/hyperdb.py:107
+#, python-format
+msgid "property %s: %r is an invalid date (%s)"
+msgstr ""
+
+#: ../roundup/hyperdb.py:124
+#, python-format
+msgid "property %s: %r is an invalid date interval (%s)"
+msgstr ""
+
+#: ../roundup/hyperdb.py:219
+#, fuzzy, python-format
+msgid "property %s: %r is not currently an element"
+msgstr "\"%(propname)s\" tulajdonság: \"%(value)s\" jelenleg nincs a listában"
+
+#: ../roundup/hyperdb.py:263
+#, python-format
+msgid "property %s: %r is not a number"
+msgstr ""
+
+#: ../roundup/hyperdb.py:276
+#, python-format
+msgid "\"%s\" not a node designator"
+msgstr ""
+
+# ../roundup/hyperdb.py:949:957
+#: ../roundup/hyperdb.py:949 ../roundup/hyperdb.py:957
+#, python-format
+msgid "Not a property name: %s"
+msgstr ""
+
+#: ../roundup/hyperdb.py:1240
+#, python-format
+msgid "property %s: %r is not a %s."
+msgstr ""
+
+#: ../roundup/hyperdb.py:1243
+#, python-format
+msgid "you may only enter ID values for property %s"
+msgstr ""
+
+#: ../roundup/hyperdb.py:1273
+#, python-format
+msgid "%r is not a property of %s"
+msgstr ""
+
+#: ../roundup/init.py:134
+#, python-format
+msgid ""
+"WARNING: directory '%s'\n"
+"\tcontains old-style template - ignored"
+msgstr ""
+"FIGYELEM: a(z) '%s' könyvtár\n"
+"\trégi típusú sablont tartalmaz - ignorálva"
+
+# ../roundup/mailgw.py:199:211
+#: ../roundup/mailgw.py:199 ../roundup/mailgw.py:211
+#, python-format
+msgid "Message signed with unknown key: %s"
+msgstr ""
+
+#: ../roundup/mailgw.py:202
+#, python-format
+msgid "Message signed with an expired key: %s"
+msgstr ""
+
+#: ../roundup/mailgw.py:205
+#, python-format
+msgid "Message signed with a revoked key: %s"
+msgstr ""
+
+#: ../roundup/mailgw.py:208
+msgid "Invalid PGP signature detected."
+msgstr ""
+
+#: ../roundup/mailgw.py:404
+msgid "Unknown multipart/encrypted version."
+msgstr ""
+
+#: ../roundup/mailgw.py:413
+msgid "Unable to decrypt your message."
+msgstr ""
+
+#: ../roundup/mailgw.py:442
+msgid "No PGP signature found in message."
+msgstr ""
+
+#: ../roundup/mailgw.py:749
+msgid ""
+"\n"
+"Emails to Roundup trackers must include a Subject: line!\n"
+msgstr ""
+"\n"
+"A Roundup hibakövetőkhöz küldött e-maileknek tartalmazniuk kell egy Subject: "
+"sort!\n"
+
+#: ../roundup/mailgw.py:873
+#, python-format
+msgid ""
+"\n"
+"The message you sent to roundup did not contain a properly formed subject\n"
+"line. The subject must contain a class name or designator to indicate the\n"
+"'topic' of the message. For example:\n"
+"    Subject: [issue] This is a new issue\n"
+"      - this will create a new issue in the tracker with the title 'This is\n"
+"        a new issue'.\n"
+"    Subject: [issue1234] This is a followup to issue 1234\n"
+"      - this will append the message's contents to the existing issue 1234\n"
+"        in the tracker.\n"
+"\n"
+"Subject was: '%(subject)s'\n"
+msgstr ""
+
+#: ../roundup/mailgw.py:911
+#, fuzzy, python-format
+msgid ""
+"\n"
+"The class name you identified in the subject line (\"%(classname)s\") does\n"
+"not exist in the database.\n"
+"\n"
+"Valid class names are: %(validname)s\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"\n"
+"A tárgy sorban megadott osztály neve (\"%(classname)s\") nem létezik\n"
+"az adatbázisban.\n"
+"\n"
+"Az érvényes osztálynevek: %(validname)s\n"
+"A tárgy ez volt: \"%(subject)s\"\n"
+
+#: ../roundup/mailgw.py:919
+#, python-format
+msgid ""
+"\n"
+"You did not identify a class name in the subject line and there is no\n"
+"default set for this tracker. The subject must contain a class name or\n"
+"designator to indicate the 'topic' of the message. For example:\n"
+"    Subject: [issue] This is a new issue\n"
+"      - this will create a new issue in the tracker with the title 'This is\n"
+"        a new issue'.\n"
+"    Subject: [issue1234] This is a followup to issue 1234\n"
+"      - this will append the message's contents to the existing issue 1234\n"
+"        in the tracker.\n"
+"\n"
+"Subject was: '%(subject)s'\n"
+msgstr ""
+
+#: ../roundup/mailgw.py:960
+#, fuzzy, python-format
+msgid ""
+"\n"
+"I cannot match your message to a node in the database - you need to either\n"
+"supply a full designator (with number, eg \"[issue123]\") or keep the\n"
+"previous subject title intact so I can match that.\n"
+"\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"\n"
+"Nem sikerült az üzenetet párosítani egy adatbázisban szereplő ággal - vagy "
+"meg kell\n"
+"adnia egy teljes nevet (számmal együtt, pl. \"[issue123]\"vagy meg kell\n"
+"tartania a teljes előző címet, hogy ahhoz lehessen párosítani.\n"
+"\n"
+"A tárgy ez volt: \"%(subject)s\"\n"
+
+#: ../roundup/mailgw.py:993
+#, python-format
+msgid ""
+"\n"
+"The node specified by the designator in the subject of your message\n"
+"(\"%(nodeid)s\") does not exist.\n"
+"\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"\n"
+"Az üzenet tárgysorában megadott ág\n"
+"(\"%(nodeid)s\") nem létezik.\n"
+"\n"
+"A tárgy ez volt: \"%(subject)s\"\n"
+
+#: ../roundup/mailgw.py:1021
+#, python-format
+msgid ""
+"\n"
+"The mail gateway is not properly set up. Please contact\n"
+"%(mailadmin)s and have them fix the incorrect class specified as:\n"
+"  %(current_class)s\n"
+msgstr ""
+"\n"
+"A mail átjáró nincs helyesen beállítva. Vegye fel a kapcsolatot\n"
+"%(mailadmin)s-nal és javíttassa ki a hibásan megadott osztályt:\n"
+"  %(current_class)s\n"
+
+#: ../roundup/mailgw.py:1044
+#, python-format
+msgid ""
+"\n"
+"The mail gateway is not properly set up. Please contact\n"
+"%(mailadmin)s and have them fix the incorrect properties:\n"
+"  %(errors)s\n"
+msgstr ""
+"\n"
+"A mail átjáró nincs helyesen beállítva. Vegye fel a kapcsolatot\n"
+"%(mailadmin)s-nal és javíttassa ki a hibás tulajdonságokat:\n"
+"  %(errors)s\n"
+
+#: ../roundup/mailgw.py:1084
+#, fuzzy, python-format
+msgid ""
+"\n"
+"You are not a registered user.%(registration_info)s\n"
+"\n"
+"Unknown address: %(from_address)s\n"
+msgstr ""
+"\n"
+"Ön nem bejegyzett felhasználó.\n"
+"\n"
+"Ismeretlen cím: %(from_address)s\n"
+
+#: ../roundup/mailgw.py:1092
+msgid "You are not permitted to access this tracker."
+msgstr "Ehhez a hibakövetőhöz hozzáférése nem engedélyezett."
+
+#: ../roundup/mailgw.py:1099
+#, python-format
+msgid "You are not permitted to edit %(classname)s."
+msgstr "Nincs jogosultsága %(classname)s szerkesztéséhez."
+
+#: ../roundup/mailgw.py:1103
+#, python-format
+msgid "You are not permitted to create %(classname)s."
+msgstr "Nincs jogosultsága %(classname)s létrehozásához."
+
+#: ../roundup/mailgw.py:1150
+#, python-format
+msgid ""
+"\n"
+"There were problems handling your subject line argument list:\n"
+"- %(errors)s\n"
+"\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"\n"
+"Probléma merült fel a tárgysor argumentum listájának feldolgozása során:\n"
+"- %(errors)s\n"
+"\n"
+"A tárgy ez volt: \"%(subject)s\"\n"
+
+#: ../roundup/mailgw.py:1203
+msgid ""
+"\n"
+"This tracker has been configured to require all email be PGP signed or\n"
+"encrypted."
+msgstr ""
+
+#: ../roundup/mailgw.py:1209
+msgid ""
+"\n"
+"Roundup requires the submission to be plain text. The message parser could\n"
+"not find a text/plain part to use.\n"
+msgstr ""
+"\n"
+"A Roundup egyszerű szövegként tudja fogadni a kérelmet. Az üzenet értelmező\n"
+"nem talált használható, egyszerű szöveg formátumú részt.\n"
+
+#: ../roundup/mailgw.py:1226
+msgid "You are not permitted to create files."
+msgstr "Nincs jogosultsága fájlok létrehozására."
+
+#: ../roundup/mailgw.py:1240
+#, python-format
+msgid "You are not permitted to add files to %(classname)s."
+msgstr "Nincs jogosultsága fájlok hozzáadására %(classname)s-hez."
+
+#: ../roundup/mailgw.py:1258
+msgid "You are not permitted to create messages."
+msgstr "Nincs jogosultsága üzenetek létrehozására."
+
+#: ../roundup/mailgw.py:1266
+#, python-format
+msgid ""
+"\n"
+"Mail message was rejected by a detector.\n"
+"%(error)s\n"
+msgstr ""
+"\n"
+"A mail üzenetet a felderítő visszutasította.\n"
+"%(error)s\n"
+
+#: ../roundup/mailgw.py:1274
+#, python-format
+msgid "You are not permitted to add messages to %(classname)s."
+msgstr "Nincs jogosultsága üzenet hozzáadására %(classname)s-hez."
+
+#: ../roundup/mailgw.py:1301
+#, python-format
+msgid "You are not permitted to edit property %(prop)s of class %(classname)s."
+msgstr ""
+"Nincs jogosultsága %(classname)s osztály %(prop)s tulajdonságát szerkeszteni."
+
+#: ../roundup/mailgw.py:1309
+#, python-format
+msgid ""
+"\n"
+"There was a problem with the message you sent:\n"
+"   %(message)s\n"
+msgstr ""
+"\n"
+"Probléma volt az Ön által küldött üzenettel:\n"
+"   %(message)s\n"
+
+#: ../roundup/mailgw.py:1331
+msgid "not of form [arg=value,value,...;arg=value,value,...]"
+msgstr "nem [arg=érték,érték,...;arg=érték,érték,...] formátumú"
+
+#: ../roundup/roundupdb.py:147
+msgid "files"
+msgstr "fájlok"
+
+#: ../roundup/roundupdb.py:147
+msgid "messages"
+msgstr "üzenetek"
+
+#: ../roundup/roundupdb.py:147
+msgid "nosy"
+msgstr "kíváncsi"
+
+#: ../roundup/roundupdb.py:147
+msgid "superseder"
+msgstr "helyettes"
+
+#: ../roundup/roundupdb.py:147
+msgid "title"
+msgstr "cím"
+
+#: ../roundup/roundupdb.py:148
+msgid "assignedto"
+msgstr "kiosztva"
+
+#: ../roundup/roundupdb.py:148
+#, fuzzy
+msgid "keyword"
+msgstr "Téma"
+
+#: ../roundup/roundupdb.py:148
+msgid "priority"
+msgstr "prioritás"
+
+#: ../roundup/roundupdb.py:148
+msgid "status"
+msgstr "állapot"
+
+#: ../roundup/roundupdb.py:151
+msgid "activity"
+msgstr "művelet"
+
+#. following properties are common for all hyperdb classes
+#. they are listed here to keep things in one place
+#: ../roundup/roundupdb.py:151
+msgid "actor"
+msgstr "végezte"
+
+#: ../roundup/roundupdb.py:151
+msgid "creation"
+msgstr "létrehozás"
+
+#: ../roundup/roundupdb.py:151
+msgid "creator"
+msgstr "létrehozó"
+
+#: ../roundup/roundupdb.py:309
+#, python-format
+msgid "New submission from %(authname)s%(authaddr)s:"
+msgstr "Új beadvány %(authname)s%(authaddr)s részéről:"
+
+#: ../roundup/roundupdb.py:312
+#, python-format
+msgid "%(authname)s%(authaddr)s added the comment:"
+msgstr "%(authname)s%(authaddr)s ezt a megjegyzést írta:"
+
+#: ../roundup/roundupdb.py:315
+#, fuzzy, python-format
+msgid "Change by %(authname)s%(authaddr)s:"
+msgstr "Új beadvány %(authname)s%(authaddr)s részéről:"
+
+#: ../roundup/roundupdb.py:342
+#, python-format
+msgid "File '%(filename)s' not attached - you can download it from %(link)s."
+msgstr ""
+
+#: ../roundup/roundupdb.py:615
+#, python-format
+msgid ""
+"\n"
+"Now:\n"
+"%(new)s\n"
+"Was:\n"
+"%(old)s"
+msgstr ""
+
+#: ../roundup/scripts/roundup_demo.py:32
+#, python-format
+msgid "Enter directory path to create demo tracker [%s]: "
+msgstr "Adja meg az elérési utat a bemutató tracker [%s] létrehozásához: "
+
+#: ../roundup/scripts/roundup_gettext.py:22
+#, python-format
+msgid "Usage: %(program)s <tracker home>"
+msgstr "Használat: %(program)s <tracker elérési út>"
+
+#: ../roundup/scripts/roundup_gettext.py:37
+#, python-format
+msgid "No tracker templates found in directory %s"
+msgstr "Nem található tracker sablon a(z) %s könyvtárban"
+
+#: ../roundup/scripts/roundup_mailgw.py:36
+#, python-format
+msgid ""
+"Usage: %(program)s [-v] [-c class] [[-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@server\n"
+" The username and password may be omitted:\n"
+"    pop username@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"
+"POPS:\n"
+" Connect to a POP server over ssl. This requires python 2.4 or later.\n"
+" This supports the same notation as POP.\n"
+"\n"
+"APOP:\n"
+" Same as POP, but using Authenticated POP:\n"
+"    apop username:password@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@server\n"
+" It also allows you to specify a specific mailbox other than INBOX using\n"
+" this format:\n"
+"    imap username:password@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@server [mailbox]\n"
+"\n"
+msgstr ""
+
+#: ../roundup/scripts/roundup_mailgw.py:151
+msgid "Error: not enough source specification information"
+msgstr "Hiba: nincs elég forrás specifikációs információ"
+
+#: ../roundup/scripts/roundup_mailgw.py:167
+msgid "Error: a later version of python is required"
+msgstr ""
+
+#: ../roundup/scripts/roundup_mailgw.py:170
+msgid "Error: pop specification not valid"
+msgstr "Hiba: a pop specifikáció nem érvényes"
+
+#: ../roundup/scripts/roundup_mailgw.py:177
+msgid "Error: apop specification not valid"
+msgstr "Hiba: az apop specifikáció nem érvényes"
+
+#: ../roundup/scripts/roundup_mailgw.py:189
+msgid ""
+"Error: The source must be either \"mailbox\", \"pop\", \"apop\", \"imap\" or "
+"\"imaps\""
+msgstr ""
+"Hiba: A forrás a következők egyike kell legyen: \"mailbox\", \"pop\", \"apop"
+"\", \"imap\" vagy \"imaps\""
+
+#: ../roundup/scripts/roundup_server.py:76
+msgid "WARNING: generating temporary SSL certificate"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:253
+msgid ""
+"<html><head><title>Roundup trackers index</title></head>\n"
+"<body><h1>Roundup trackers index</h1><ol>\n"
+msgstr ""
+"<html><head><title>Roundup hibakövetők listája</title></head>\n"
+"<body><h1>Roundup hibakövetők listája</h1><ol>\n"
+
+#: ../roundup/scripts/roundup_server.py:389
+#, python-format
+msgid "Error: %s: %s"
+msgstr "Hiba: %s: %s"
+
+#: ../roundup/scripts/roundup_server.py:399
+msgid "WARNING: ignoring \"-g\" argument, not root"
+msgstr "FIGYELEM: \"-g\" opció figyelmen kívül hagyásra került, nem root"
+
+#: ../roundup/scripts/roundup_server.py:405
+msgid "Can't change groups - no grp module"
+msgstr "Nem lehet csoportot váltani - nincs meg a grp modul"
+
+#: ../roundup/scripts/roundup_server.py:414
+#, python-format
+msgid "Group %(group)s doesn't exist"
+msgstr "%(group)s csoport nem létezik"
+
+#: ../roundup/scripts/roundup_server.py:425
+msgid "Can't run as root!"
+msgstr "Nem futhat root-ként!"
+
+#: ../roundup/scripts/roundup_server.py:428
+msgid "WARNING: ignoring \"-u\" argument, not root"
+msgstr "FIGYELEM: \"-u\" opció figyelmen kívül hagyásra került, nem root"
+
+#: ../roundup/scripts/roundup_server.py:434
+msgid "Can't change users - no pwd module"
+msgstr "Felhasználóváltás nem sikerült - nincs pwd modul"
+
+#: ../roundup/scripts/roundup_server.py:443
+#, python-format
+msgid "User %(user)s doesn't exist"
+msgstr "A(z) %(user)s felhasználó nem létezik"
+
+#: ../roundup/scripts/roundup_server.py:592
+#, python-format
+msgid "Multiprocess mode \"%s\" is not available, switching to single-process"
+msgstr "\"%s\" többszálú mód nem érhető el, áttérés egyszálú módra"
+
+#: ../roundup/scripts/roundup_server.py:620
+#, python-format
+msgid "Unable to bind to port %s, port already in use."
+msgstr "Nem sikerült a(z) %s portra csatlakozni, a port már használatban van."
+
+#: ../roundup/scripts/roundup_server.py:688
+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:695
+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:702
+#, 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"
+" -i <fname>    set tracker index template\n"
+" -s            enable SSL\n"
+" -e <fname>    PEM file containing SSL key and certificate\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:860
+msgid "Instances must be name=home"
+msgstr "A példányoknak név=home formában kell lenniük"
+
+#: ../roundup/scripts/roundup_server.py:874
+#, python-format
+msgid "Configuration saved to %s"
+msgstr "Beállítások elmentve ide: %s"
+
+#: ../roundup/scripts/roundup_server.py:892
+msgid "Sorry, you can't run the server as a daemon on this Operating System"
+msgstr ""
+"Elnézést, ezen az operációs rendszeren a szerver nem indítható démonként"
+
+#: ../roundup/scripts/roundup_server.py:907
+#, python-format
+msgid "Roundup server started on %(HOST)s:%(PORT)s"
+msgstr "Roundup server elindítva a(z) %(HOST)s:%(PORT)s gépen"
+
+#: ../templates/classic/html/_generic.collision.html:4
+#: ../templates/minimal/html/_generic.collision.html:4
+msgid "${class} Edit Collision - ${tracker}"
+msgstr "${class} ütközés szerkesztése - ${tracker}"
+
+#: ../templates/classic/html/_generic.collision.html:7
+#: ../templates/minimal/html/_generic.collision.html:7
+msgid "${class} Edit Collision"
+msgstr "${class} ütközés szerkesztése"
+
+#: ../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"
+"  Ütközés történt. Egy másik felhasználó módosította ezt a bejegyzést\n"
+"  mialatt Ön is szerkesztette. <a href='${context}'>Olvassa újra</a>\n"
+"  a bejegyzést és szerkessze újra azt.\n"
+
+#: ../templates/classic/html/_generic.help-empty.html:6
+msgid "Please specify your search parameters!"
+msgstr ""
+
+#: ../templates/classic/html/_generic.help-list.html:20
+#: ../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:35
+#: ../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:35
+#: ../templates/minimal/html/user.register.html:14
+msgid "You are not allowed to view this page."
+msgstr "Nincs jogosultsága az oldal megjelenítéséhez."
+
+#: ../templates/classic/html/_generic.help-list.html:34
+msgid "1..25 out of 50"
+msgstr ""
+
+#: ../templates/classic/html/_generic.help-search.html:9
+msgid ""
+"Generic template ${template} or version for class ${classname} is not yet "
+"implemented"
+msgstr ""
+
+#: ../templates/classic/html/_generic.help-submit.html:57
+#: ../templates/classic/html/_generic.help.html:31
+#: ../templates/minimal/html/_generic.help.html:31
+msgid " Cancel "
+msgstr " Mégsem "
+
+#: ../templates/classic/html/_generic.help-submit.html:63
+#: ../templates/classic/html/_generic.help.html:34
+#: ../templates/minimal/html/_generic.help.html:34
+msgid " Apply "
+msgstr " Alkalmaz "
+
+#: ../templates/classic/html/_generic.help.html:9
+#: ../templates/classic/html/user.help.html:13
+#: ../templates/minimal/html/_generic.help.html:9
+msgid "${property} help - ${tracker}"
+msgstr "${property} segítség - ${tracker}"
+
+#: ../templates/classic/html/_generic.help.html:41
+#: ../templates/classic/html/help.html:21
+#: ../templates/classic/html/issue.index.html:81
+#: ../templates/minimal/html/_generic.help.html:41
+msgid "&lt;&lt; previous"
+msgstr "&lt;&lt; előző"
+
+#: ../templates/classic/html/_generic.help.html:53
+#: ../templates/classic/html/help.html:28
+#: ../templates/classic/html/issue.index.html:89
+#: ../templates/minimal/html/_generic.help.html:53
+msgid "${start}..${end} out of ${total}"
+msgstr "${start}..${end}, összesen ${total}"
+
+#: ../templates/classic/html/_generic.help.html:57
+#: ../templates/classic/html/help.html:32
+#: ../templates/classic/html/issue.index.html:92
+#: ../templates/minimal/html/_generic.help.html:57
+msgid "next &gt;&gt;"
+msgstr "következő &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} szerkesztése - ${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} szerkesztése"
+
+#: ../templates/classic/html/_generic.index.html:19
+#: ../templates/classic/html/_generic.item.html:16
+#: ../templates/classic/html/file.item.html:13
+#: ../templates/classic/html/issue.index.html:20
+#: ../templates/classic/html/issue.item.html:32
+#: ../templates/classic/html/msg.item.html:30
+#: ../templates/classic/html/user.index.html:13
+#: ../templates/classic/html/user.item.html:39
+#: ../templates/minimal/html/_generic.index.html:19
+#: ../templates/minimal/html/_generic.item.html:17
+#: ../templates/minimal/html/user.index.html:13
+#: ../templates/minimal/html/user.item.html:39
+#: ../templates/minimal/html/user.register.html:17
+msgid "Please login with your username and password."
+msgstr ""
+
+#: ../templates/classic/html/_generic.index.html:28
+#: ../templates/minimal/html/_generic.index.html:28
+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:50
+#: ../templates/minimal/html/_generic.index.html:50
+msgid "Edit Items"
+msgstr "Elemek szerkesztése"
+
+#: ../templates/classic/html/file.index.html:4
+msgid "List of files - ${tracker}"
+msgstr "Fájlok listája - ${tracker}"
+
+#: ../templates/classic/html/file.index.html:5
+msgid "List of files"
+msgstr "Fájlok listája"
+
+#: ../templates/classic/html/file.index.html:10
+msgid "Download"
+msgstr "Letöltés"
+
+#: ../templates/classic/html/file.index.html:11
+#: ../templates/classic/html/file.item.html:27
+msgid "Content Type"
+msgstr "Tartalom típus"
+
+#: ../templates/classic/html/file.index.html:12
+msgid "Uploaded By"
+msgstr "Feltöltötte"
+
+#: ../templates/classic/html/file.index.html:13
+#: ../templates/classic/html/msg.item.html:48
+msgid "Date"
+msgstr "Dátum"
+
+#: ../templates/classic/html/file.item.html:2
+msgid "File display - ${tracker}"
+msgstr "Fájl megjelenítés - ${tracker}"
+
+#: ../templates/classic/html/file.item.html:4
+msgid "File display"
+msgstr "Fájl megjelenítés"
+
+#: ../templates/classic/html/file.item.html:23
+#: ../templates/classic/html/user.register.html:17
+msgid "Name"
+msgstr "Név"
+
+#: ../templates/classic/html/file.item.html:45
+msgid "download"
+msgstr "letöltés"
+
+#: ../templates/classic/html/home.classlist.html:2
+#: ../templates/minimal/html/home.classlist.html:2
+msgid "List of classes - ${tracker}"
+msgstr "Osztályok listája - ${tracker}"
+
+#: ../templates/classic/html/home.classlist.html:4
+#: ../templates/minimal/html/home.classlist.html:4
+msgid "List of classes"
+msgstr "Osztályok listája"
+
+#: ../templates/classic/html/issue.index.html:4
+#: ../templates/classic/html/issue.index.html:10
+msgid "List of issues"
+msgstr "Ügyek listája"
+
+#: ../templates/classic/html/issue.index.html:27
+#: ../templates/classic/html/issue.item.html:49
+msgid "Priority"
+msgstr "Prioritás"
+
+#: ../templates/classic/html/issue.index.html:28
+msgid "ID"
+msgstr "Azonosító"
+
+#: ../templates/classic/html/issue.index.html:29
+msgid "Creation"
+msgstr "Létrehozás"
+
+#: ../templates/classic/html/issue.index.html:30
+msgid "Activity"
+msgstr "Aktivitás"
+
+#: ../templates/classic/html/issue.index.html:31
+msgid "Actor"
+msgstr "Hozzászóló"
+
+#: ../templates/classic/html/issue.index.html:32
+#: ../templates/classic/html/keyword.item.html:37
+msgid "Keyword"
+msgstr "Téma"
+
+#: ../templates/classic/html/issue.index.html:33
+#: ../templates/classic/html/issue.item.html:44
+msgid "Title"
+msgstr "Cím"
+
+#: ../templates/classic/html/issue.index.html:34
+#: ../templates/classic/html/issue.item.html:51
+msgid "Status"
+msgstr "Állapot"
+
+#: ../templates/classic/html/issue.index.html:35
+msgid "Creator"
+msgstr "Létrehozó"
+
+#: ../templates/classic/html/issue.index.html:36
+msgid "Assigned&nbsp;To"
+msgstr "Kiosztva"
+
+#: ../templates/classic/html/issue.index.html:105
+msgid "Download as CSV"
+msgstr "Letöltés CSV-ként"
+
+#: ../templates/classic/html/issue.index.html:115
+msgid "Sort on:"
+msgstr "Rendezés:"
+
+#: ../templates/classic/html/issue.index.html:119
+#: ../templates/classic/html/issue.index.html:140
+msgid "- nothing -"
+msgstr "- semmi -"
+
+#: ../templates/classic/html/issue.index.html:127
+#: ../templates/classic/html/issue.index.html:148
+msgid "Descending:"
+msgstr "Csökkenő:"
+
+#: ../templates/classic/html/issue.index.html:136
+msgid "Group on:"
+msgstr "Csoportosítás:"
+
+#: ../templates/classic/html/issue.index.html:155
+msgid "Redisplay"
+msgstr "Megjelenítés újra"
+
+#: ../templates/classic/html/issue.item.html:7
+msgid "Issue ${id}: ${title} - ${tracker}"
+msgstr "${id}. ügy: ${title} - ${tracker}"
+
+#: ../templates/classic/html/issue.item.html:10
+msgid "New Issue - ${tracker}"
+msgstr "Új ügy - ${tracker}"
+
+#: ../templates/classic/html/issue.item.html:14
+msgid "New Issue"
+msgstr "Új ügy"
+
+#: ../templates/classic/html/issue.item.html:16
+msgid "New Issue Editing"
+msgstr "Új ügy szerkesztése"
+
+#: ../templates/classic/html/issue.item.html:19
+msgid "Issue${id}"
+msgstr "${id}. ügy"
+
+#: ../templates/classic/html/issue.item.html:22
+msgid "Issue${id} Editing"
+msgstr "${id}. ügy szerkesztése"
+
+#: ../templates/classic/html/issue.item.html:56
+msgid "Superseder"
+msgstr "Helyettesítő"
+
+#: ../templates/classic/html/issue.item.html:61
+msgid "View:"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:67
+msgid "Nosy List"
+msgstr "Kíváncsiak listája"
+
+#: ../templates/classic/html/issue.item.html:76
+msgid "Assigned To"
+msgstr "Kiosztva"
+
+#: ../templates/classic/html/issue.item.html:78
+#: ../templates/classic/html/page.html:103
+#: ../templates/minimal/html/page.html:102
+msgid "Keywords"
+msgstr "Témák"
+
+#: ../templates/classic/html/issue.item.html:86
+msgid "Change Note"
+msgstr "Megjegyzés módosítása"
+
+#: ../templates/classic/html/issue.item.html:94
+msgid "File"
+msgstr "Fájl"
+
+#: ../templates/classic/html/issue.item.html:106
+msgid "Make a copy"
+msgstr "Másolat készítése"
+
+#: ../templates/classic/html/issue.item.html:114
+#: ../templates/classic/html/user.item.html:153
+#: ../templates/classic/html/user.register.html:69
+#: ../templates/minimal/html/user.item.html:153
+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>Megjegyzés:&nbsp;</td> <th class=\"required"
+"\">a kiemelt</th> <td>&nbsp;mezők szükségesek.</td> </tr> </table>"
+
+#: ../templates/classic/html/issue.item.html:128
+msgid ""
+"Created on <b>${creation}</b> by <b>${creator}</b>, last changed <b>"
+"${activity}</b> by <b>${actor}</b>."
+msgstr ""
+"<b>${creation}</b> létrehozta <b>${creator}</b>, utoljára <b>${actor}</b> "
+"módosította <b>${activity}</b>-kor."
+
+#: ../templates/classic/html/issue.item.html:132
+#: ../templates/classic/html/msg.item.html:61
+msgid "Files"
+msgstr "Fájlok"
+
+#: ../templates/classic/html/issue.item.html:134
+#: ../templates/classic/html/msg.item.html:63
+msgid "File name"
+msgstr "Fájlnév"
+
+#: ../templates/classic/html/issue.item.html:135
+#: ../templates/classic/html/msg.item.html:64
+msgid "Uploaded"
+msgstr "Feltöltve"
+
+#: ../templates/classic/html/issue.item.html:136
+msgid "Type"
+msgstr "Típus"
+
+#: ../templates/classic/html/issue.item.html:137
+#: ../templates/classic/html/query.edit.html:30
+msgid "Edit"
+msgstr "Szerkesztés"
+
+#: ../templates/classic/html/issue.item.html:138
+msgid "Remove"
+msgstr "Törlés"
+
+#: ../templates/classic/html/issue.item.html:158
+#: ../templates/classic/html/issue.item.html:179
+#: ../templates/classic/html/query.edit.html:50
+msgid "remove"
+msgstr "Törlés"
+
+#: ../templates/classic/html/issue.item.html:165
+#: ../templates/classic/html/msg.index.html:9
+msgid "Messages"
+msgstr "Üzenetek"
+
+#: ../templates/classic/html/issue.item.html:169
+msgid "msg${id} (view)"
+msgstr "${id}. üzenet"
+
+#: ../templates/classic/html/issue.item.html:170
+msgid "Author: ${author}"
+msgstr "Szerző: ${author}"
+
+#: ../templates/classic/html/issue.item.html:172
+msgid "Date: ${date}"
+msgstr "Dátum: ${date}"
+
+#: ../templates/classic/html/issue.search.html:2
+msgid "Issue searching - ${tracker}"
+msgstr "Ügy keresése - ${tracker}"
+
+#: ../templates/classic/html/issue.search.html:4
+msgid "Issue searching"
+msgstr "Ügy keresése"
+
+#: ../templates/classic/html/issue.search.html:31
+msgid "Filter on"
+msgstr "Szűrés"
+
+#: ../templates/classic/html/issue.search.html:32
+msgid "Display"
+msgstr "Megjelenítés"
+
+#: ../templates/classic/html/issue.search.html:33
+msgid "Sort on"
+msgstr "Rendezés"
+
+#: ../templates/classic/html/issue.search.html:34
+msgid "Group on"
+msgstr "Csoportosítás"
+
+#: ../templates/classic/html/issue.search.html:38
+msgid "All text*:"
+msgstr "Minden szöveg*:"
+
+#: ../templates/classic/html/issue.search.html:46
+msgid "Title:"
+msgstr "Cím:"
+
+#: ../templates/classic/html/issue.search.html:56
+#, fuzzy
+msgid "Keyword:"
+msgstr "Téma"
+
+#: ../templates/classic/html/issue.search.html:58
+#: ../templates/classic/html/issue.search.html:123
+#: ../templates/classic/html/issue.search.html:139
+msgid "not selected"
+msgstr "nem kijelölt"
+
+#: ../templates/classic/html/issue.search.html:67
+msgid "ID:"
+msgstr "Azonosító:"
+
+#: ../templates/classic/html/issue.search.html:75
+msgid "Creation Date:"
+msgstr "Létrehozás dátuma:"
+
+#: ../templates/classic/html/issue.search.html:86
+msgid "Creator:"
+msgstr "Létrehozó:"
+
+#: ../templates/classic/html/issue.search.html:88
+msgid "created by me"
+msgstr "én készítettem"
+
+#: ../templates/classic/html/issue.search.html:97
+msgid "Activity:"
+msgstr "Művelet:"
+
+#: ../templates/classic/html/issue.search.html:108
+msgid "Actor:"
+msgstr "Hozzászóló:"
+
+#: ../templates/classic/html/issue.search.html:110
+msgid "done by me"
+msgstr "saját magam"
+
+#: ../templates/classic/html/issue.search.html:121
+msgid "Priority:"
+msgstr "Prioritás:"
+
+#: ../templates/classic/html/issue.search.html:134
+msgid "Status:"
+msgstr "Állapot:"
+
+#: ../templates/classic/html/issue.search.html:137
+msgid "not resolved"
+msgstr "nem megoldott"
+
+#: ../templates/classic/html/issue.search.html:152
+msgid "Assigned to:"
+msgstr "Kiadva:"
+
+#: ../templates/classic/html/issue.search.html:155
+msgid "assigned to me"
+msgstr "nekem adva"
+
+#: ../templates/classic/html/issue.search.html:157
+msgid "unassigned"
+msgstr "gazdátlan"
+
+#: ../templates/classic/html/issue.search.html:167
+msgid "No Sort or group:"
+msgstr "Ne rendezze vagy csoportosítsa:"
+
+#: ../templates/classic/html/issue.search.html:175
+msgid "Pagesize:"
+msgstr "Oldalméret:"
+
+#: ../templates/classic/html/issue.search.html:181
+msgid "Start With:"
+msgstr "Kezdés:"
+
+#: ../templates/classic/html/issue.search.html:187
+msgid "Sort Descending:"
+msgstr "Csökkenő rendezés:"
+
+#: ../templates/classic/html/issue.search.html:194
+msgid "Group Descending:"
+msgstr "Csökkenő csoportosítás:"
+
+#: ../templates/classic/html/issue.search.html:201
+msgid "Query name**:"
+msgstr "Lekérdezés neve**:"
+
+#: ../templates/classic/html/issue.search.html:213
+#: ../templates/classic/html/page.html:43
+#: ../templates/classic/html/page.html:92
+#: ../templates/classic/html/user.help-search.html:69
+#: ../templates/minimal/html/page.html:43
+#: ../templates/minimal/html/page.html:91
+msgid "Search"
+msgstr "Keresés"
+
+#: ../templates/classic/html/issue.search.html:218
+msgid "*: The \"all text\" field will look in message bodies and issue titles"
+msgstr ""
+"*: A \"Minden szöveg\" mező az üzenetek címsorában és belsejében is keres"
+
+#: ../templates/classic/html/issue.search.html:221
+msgid ""
+"**: If you supply a name, the query will be saved off and available as a "
+"link in the sidebar"
+msgstr ""
+"**: Ha megad egy nevet, a lekérdezés elmentésre kerül és az oldalsávon "
+"elérhető lesz"
+
+#: ../templates/classic/html/keyword.item.html:3
+msgid "Keyword editing - ${tracker}"
+msgstr "Témák szerkesztése - ${tracker}"
+
+#: ../templates/classic/html/keyword.item.html:5
+msgid "Keyword editing"
+msgstr "Téma szerkesztése"
+
+#: ../templates/classic/html/keyword.item.html:11
+msgid "Existing Keywords"
+msgstr "Létező témák"
+
+#: ../templates/classic/html/keyword.item.html:20
+msgid ""
+"To edit an existing keyword (for spelling or typing errors), click on its "
+"entry above."
+msgstr ""
+"Meglévő téma szerkesztéséhez (helyesírási hibák) kattintson a fenti elemre."
+
+#: ../templates/classic/html/keyword.item.html:27
+msgid "To create a new keyword, enter it below and click \"Submit New Entry\"."
+msgstr ""
+"Új téma létrehozásához adja meg alább, majd kattintson a \"Létrehozás\" "
+"gombra."
+
+#: ../templates/classic/html/msg.index.html:3
+msgid "List of messages - ${tracker}"
+msgstr "Üzenetek listája - ${tracker}"
+
+#: ../templates/classic/html/msg.index.html:5
+msgid "Message listing"
+msgstr "Üzenetek listája"
+
+#: ../templates/classic/html/msg.item.html:6
+msgid "Message ${id} - ${tracker}"
+msgstr "${id}. üzenet  - ${tracker}"
+
+#: ../templates/classic/html/msg.item.html:9
+msgid "New Message - ${tracker}"
+msgstr "Új üzenet - ${tracker}"
+
+#: ../templates/classic/html/msg.item.html:13
+msgid "New Message"
+msgstr "Új üzenet"
+
+#: ../templates/classic/html/msg.item.html:15
+msgid "New Message Editing"
+msgstr "Új üzenet szerkesztése"
+
+#: ../templates/classic/html/msg.item.html:18
+msgid "Message${id}"
+msgstr "${id}. üzenet"
+
+#: ../templates/classic/html/msg.item.html:21
+msgid "Message${id} Editing"
+msgstr "${id}. üzenet szerkesztése"
+
+#: ../templates/classic/html/msg.item.html:38
+msgid "Author"
+msgstr "Szerző"
+
+#: ../templates/classic/html/msg.item.html:43
+msgid "Recipients"
+msgstr "Címzettek"
+
+#: ../templates/classic/html/msg.item.html:54
+msgid "Content"
+msgstr "Tartalom"
+
+#: ../templates/classic/html/page.html:54
+#: ../templates/minimal/html/page.html:53
+msgid "<b>Your Queries</b> (<a href=\"query?@template=edit\">edit</a>)"
+msgstr "<b>Lekérdezések</b> (<a href=\"query?@template=edit\">szerk.</a>)"
+
+#: ../templates/classic/html/page.html:65
+#: ../templates/minimal/html/page.html:64
+msgid "Issues"
+msgstr "Ügyek"
+
+#: ../templates/classic/html/page.html:67
+#: ../templates/classic/html/page.html:105
+#: ../templates/minimal/html/page.html:66
+#: ../templates/minimal/html/page.html:104
+msgid "Create New"
+msgstr "Új létrehozása"
+
+#: ../templates/classic/html/page.html:69
+#: ../templates/minimal/html/page.html:68
+msgid "Show Unassigned"
+msgstr "Gazdátlanok mutatása"
+
+#: ../templates/classic/html/page.html:81
+#: ../templates/minimal/html/page.html:80
+msgid "Show All"
+msgstr "Mutasd mind"
+
+#: ../templates/classic/html/page.html:93
+#: ../templates/minimal/html/page.html:92
+msgid "Show issue:"
+msgstr "Ügy mutatása:"
+
+#: ../templates/classic/html/page.html:108
+#: ../templates/minimal/html/page.html:107
+msgid "Edit Existing"
+msgstr "Meglévők szerkesztése"
+
+#: ../templates/classic/html/page.html:114
+#: ../templates/minimal/html/page.html:113
+msgid "Administration"
+msgstr "Adminisztráció"
+
+#: ../templates/classic/html/page.html:116
+#: ../templates/minimal/html/page.html:115
+msgid "Class List"
+msgstr "Osztályok listája"
+
+#: ../templates/classic/html/page.html:120
+#: ../templates/minimal/html/page.html:119
+msgid "User List"
+msgstr "Felhasználók listája"
+
+#: ../templates/classic/html/page.html:122
+#: ../templates/minimal/html/page.html:121
+msgid "Add User"
+msgstr "Felhasználó hozzáadása"
+
+#: ../templates/classic/html/page.html:129
+#: ../templates/classic/html/page.html:135
+#: ../templates/minimal/html/page.html:128
+#: ../templates/minimal/html/page.html:134
+msgid "Login"
+msgstr "Bejelentkezés"
+
+#: ../templates/classic/html/page.html:134
+#: ../templates/minimal/html/page.html:133
+msgid "Remember me?"
+msgstr "Emlékezzen?"
+
+#: ../templates/classic/html/page.html:138
+#: ../templates/classic/html/user.register.html:63
+#: ../templates/minimal/html/page.html:137
+#: ../templates/minimal/html/user.register.html:61
+msgid "Register"
+msgstr "Regisztráció"
+
+#: ../templates/classic/html/page.html:141
+#: ../templates/minimal/html/page.html:140
+msgid "Lost&nbsp;your&nbsp;login?"
+msgstr "Elveszett&nbsp;a&nbsp;jelszava?"
+
+#: ../templates/classic/html/page.html:146
+#: ../templates/minimal/html/page.html:145
+msgid "Hello, ${user}"
+msgstr "Helló, ${user}"
+
+#: ../templates/classic/html/page.html:148
+msgid "Your Issues"
+msgstr "Saját ügyek"
+
+#: ../templates/classic/html/page.html:160
+#: ../templates/minimal/html/page.html:147
+msgid "Your Details"
+msgstr "Saját adatok"
+
+#: ../templates/classic/html/page.html:162
+#: ../templates/minimal/html/page.html:149
+msgid "Logout"
+msgstr "Kijelentkezés"
+
+#: ../templates/classic/html/page.html:166
+#: ../templates/minimal/html/page.html:153
+msgid "Help"
+msgstr "Segítség"
+
+#: ../templates/classic/html/page.html:167
+#: ../templates/minimal/html/page.html:154
+msgid "Roundup docs"
+msgstr "Roundup dokumentáció"
+
+#: ../templates/classic/html/page.html:177
+#: ../templates/minimal/html/page.html:164
+msgid "clear this message"
+msgstr "üzenet törlése"
+
+#: ../templates/classic/html/page.html:241
+#: ../templates/classic/html/page.html:256
+#: ../templates/classic/html/page.html:270
+#: ../templates/minimal/html/page.html:228
+#: ../templates/minimal/html/page.html:243
+#: ../templates/minimal/html/page.html:257
+msgid "don't care"
+msgstr "mindegy"
+
+#: ../templates/classic/html/page.html:243
+#: ../templates/classic/html/page.html:258
+#: ../templates/classic/html/page.html:271
+#: ../templates/minimal/html/page.html:230
+#: ../templates/minimal/html/page.html:245
+#: ../templates/minimal/html/page.html:258
+msgid "------------"
+msgstr "------------"
+
+#: ../templates/classic/html/page.html:299
+#: ../templates/minimal/html/page.html:286
+msgid "no value"
+msgstr "nincs érték"
+
+#: ../templates/classic/html/query.edit.html:4
+msgid "\"Your Queries\" Editing - ${tracker}"
+msgstr "\"Saját lekérdezések\" szerkesztése - ${tracker}"
+
+#: ../templates/classic/html/query.edit.html:6
+msgid "\"Your Queries\" Editing"
+msgstr "\"Saját lekérdezések\" szerkesztése"
+
+#: ../templates/classic/html/query.edit.html:11
+msgid "You are not allowed to edit queries."
+msgstr "Nincs jogosultsága lekérdezések szerkesztéséhez."
+
+#: ../templates/classic/html/query.edit.html:28
+msgid "Query"
+msgstr "Lekérdezés"
+
+#: ../templates/classic/html/query.edit.html:29
+msgid "Include in \"Your Queries\""
+msgstr "Hozzáadás a \"Saját lekérdezések\"-hez"
+
+#: ../templates/classic/html/query.edit.html:31
+msgid "Private to you?"
+msgstr "Csak saját használatra?"
+
+#: ../templates/classic/html/query.edit.html:44
+msgid "leave out"
+msgstr "kihagy"
+
+#: ../templates/classic/html/query.edit.html:45
+msgid "include"
+msgstr "bevesz"
+
+#: ../templates/classic/html/query.edit.html:49
+msgid "leave in"
+msgstr "bent hagy"
+
+#: ../templates/classic/html/query.edit.html:54
+msgid "[query is retired]"
+msgstr "[lekérdezés visszavonva]"
+
+#: ../templates/classic/html/query.edit.html:67
+#: ../templates/classic/html/query.edit.html:94
+msgid "edit"
+msgstr "szerkesztés"
+
+#: ../templates/classic/html/query.edit.html:71
+msgid "yes"
+msgstr "igen"
+
+#: ../templates/classic/html/query.edit.html:73
+msgid "no"
+msgstr "nem"
+
+#: ../templates/classic/html/query.edit.html:79
+msgid "Delete"
+msgstr "Törlés"
+
+#: ../templates/classic/html/query.edit.html:96
+msgid "[not yours to edit]"
+msgstr "[nem saját szerkesztésű]"
+
+#: ../templates/classic/html/query.edit.html:104
+msgid "Save Selection"
+msgstr "Kijelölés mentése"
+
+#: ../templates/classic/html/user.forgotten.html:3
+msgid "Password reset request - ${tracker}"
+msgstr "Jelszó törlés kérése - ${tracker}"
+
+#: ../templates/classic/html/user.forgotten.html:5
+msgid "Password reset request"
+msgstr "Jelszó törlés kérése"
+
+#: ../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 ""
+"Két lehetősége van, ha elfelejtette a jelszavát. Ha tudja a regisztrációs e-"
+"mail címét, adja meg alább."
+
+#: ../templates/classic/html/user.forgotten.html:16
+msgid "Email Address:"
+msgstr "E-mail cím:"
+
+#: ../templates/classic/html/user.forgotten.html:24
+#: ../templates/classic/html/user.forgotten.html:34
+msgid "Request password reset"
+msgstr "Kérjen jelszó törlést"
+
+#: ../templates/classic/html/user.forgotten.html:30
+msgid "Or, if you know your username, then enter it below."
+msgstr "Vagy, ha ismeri a felhasználónevet, adja meg alább."
+
+#: ../templates/classic/html/user.forgotten.html:33
+msgid "Username:"
+msgstr "Felhasználónév:"
+
+#: ../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 ""
+"A rendszer egy megerősítő e-mailt küld Önnek - kövesse a benne foglaltakat a "
+"visszaállítási folyamat befejezéséhez."
+
+#: ../templates/classic/html/user.help-search.html:73
+#, fuzzy
+msgid "Pagesize"
+msgstr "Oldalméret:"
+
+#: ../templates/classic/html/user.help.html:43
+msgid ""
+"Your browser is not capable of using frames; you should be redirected "
+"immediately, or visit ${link}."
+msgstr ""
+
+#: ../templates/classic/html/user.index.html:3
+#: ../templates/minimal/html/user.index.html:3
+msgid "User listing - ${tracker}"
+msgstr "Felhasználók listája - ${tracker}"
+
+#: ../templates/classic/html/user.index.html:5
+#: ../templates/minimal/html/user.index.html:5
+msgid "User listing"
+msgstr "Felhasználók listája"
+
+#: ../templates/classic/html/user.index.html:19
+#: ../templates/minimal/html/user.index.html:19
+msgid "Username"
+msgstr "Felhasználónév"
+
+#: ../templates/classic/html/user.index.html:20
+msgid "Real name"
+msgstr "Valódi név"
+
+#: ../templates/classic/html/user.index.html:21
+#: ../templates/classic/html/user.register.html:45
+msgid "Organisation"
+msgstr "Szervezet"
+
+#: ../templates/classic/html/user.index.html:22
+#: ../templates/minimal/html/user.index.html:20
+msgid "Email address"
+msgstr "E-mail cím"
+
+#: ../templates/classic/html/user.index.html:23
+msgid "Phone number"
+msgstr "Telefonszám"
+
+#: ../templates/classic/html/user.index.html:24
+msgid "Retire"
+msgstr "Visszavonulás"
+
+#: ../templates/classic/html/user.index.html:37
+msgid "retire"
+msgstr "visszavonulás"
+
+#: ../templates/classic/html/user.item.html:9
+#: ../templates/minimal/html/user.item.html:9
+msgid "User ${id}: ${title} - ${tracker}"
+msgstr "${id}. felhasználó: ${title} - ${tracker}"
+
+#: ../templates/classic/html/user.item.html:12
+#: ../templates/minimal/html/user.item.html:12
+msgid "New User - ${tracker}"
+msgstr "Új felhasználó - ${tracker}"
+
+#: ../templates/classic/html/user.item.html:21
+#: ../templates/minimal/html/user.item.html:21
+msgid "New User"
+msgstr "Új felhasználó"
+
+#: ../templates/classic/html/user.item.html:23
+#: ../templates/minimal/html/user.item.html:23
+msgid "New User Editing"
+msgstr "Új felhasználó szerkesztése"
+
+#: ../templates/classic/html/user.item.html:26
+#: ../templates/minimal/html/user.item.html:26
+msgid "User${id}"
+msgstr "${id}. felhasználó"
+
+#: ../templates/classic/html/user.item.html:29
+#: ../templates/minimal/html/user.item.html:29
+msgid "User${id} Editing"
+msgstr "${id}. felhasználó szerkesztése"
+
+#: ../templates/classic/html/user.item.html:80
+#: ../templates/classic/html/user.register.html:33
+#: ../templates/minimal/html/user.item.html:80
+#: ../templates/minimal/html/user.register.html:41
+msgid "Roles"
+msgstr "Szerepkörök"
+
+#: ../templates/classic/html/user.item.html:88
+#: ../templates/minimal/html/user.item.html:88
+msgid "(to give the user more than one role, enter a comma,separated,list)"
+msgstr ""
+"(egynél több szerepkör megadásához vesszővel,elválasztott,listát,adjon,meg)"
+
+#: ../templates/classic/html/user.item.html:109
+#: ../templates/minimal/html/user.item.html:109
+msgid "(this is a numeric hour offset, the default is ${zone})"
+msgstr "(ez egy numerikus óra eltolás, ${zone} az alapértelmezett)"
+
+#: ../templates/classic/html/user.item.html:130
+#: ../templates/classic/html/user.register.html:53
+#: ../templates/minimal/html/user.item.html:130
+#: ../templates/minimal/html/user.register.html:53
+msgid "Alternate E-mail addresses<br>One address per line"
+msgstr "Alternatív e-mail címek <br>soronként egy cím"
+
+#: ../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 "Regisztrálás a következőnél: ${tracker}"
+
+#: ../templates/classic/html/user.register.html:21
+#: ../templates/minimal/html/user.register.html:29
+msgid "Login Name"
+msgstr "Bejelentkezési név"
+
+#: ../templates/classic/html/user.register.html:25
+#: ../templates/minimal/html/user.register.html:33
+msgid "Login Password"
+msgstr "Bejelentkezési jelszó"
+
+#: ../templates/classic/html/user.register.html:29
+#: ../templates/minimal/html/user.register.html:37
+msgid "Confirm Password"
+msgstr "Jelszó megerősítése"
+
+#: ../templates/classic/html/user.register.html:41
+msgid "Phone"
+msgstr "Telefon"
+
+#: ../templates/classic/html/user.register.html:49
+#: ../templates/minimal/html/user.register.html:49
+msgid "E-mail address"
+msgstr "E-mail címek"
+
+#: ../templates/classic/html/user.rego_progress.html:4
+#: ../templates/minimal/html/user.rego_progress.html:4
+msgid "Registration in progress - ${tracker}"
+msgstr "Regisztráció folyamatban - ${tracker}"
+
+#: ../templates/classic/html/user.rego_progress.html:6
+#: ../templates/minimal/html/user.rego_progress.html:6
+msgid "Registration in progress..."
+msgstr "Regisztráció folyamatban..."
+
+#: ../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 ""
+"Rövidesen kapni fog egy e-mailt a regisztrációjának megerősítésére. A "
+"regisztráció befejezéséhet kövesse a levélben lévő linket."
+
+# priority translations:
+#: ../templates/classic/initial_data.py:5
+msgid "critical"
+msgstr "kritikus"
+
+#: ../templates/classic/initial_data.py:6
+msgid "urgent"
+msgstr "sürgős"
+
+#: ../templates/classic/initial_data.py:7
+msgid "bug"
+msgstr "hiba"
+
+#: ../templates/classic/initial_data.py:8
+msgid "feature"
+msgstr "szolgáltatás"
+
+#: ../templates/classic/initial_data.py:9
+msgid "wish"
+msgstr "óhaj"
+
+# status translations:
+#: ../templates/classic/initial_data.py:12
+msgid "unread"
+msgstr "nem olvasott"
+
+#: ../templates/classic/initial_data.py:13
+msgid "deferred"
+msgstr "elutasítva"
+
+#: ../templates/classic/initial_data.py:14
+msgid "chatting"
+msgstr "megbeszélés"
+
+#: ../templates/classic/initial_data.py:15
+msgid "need-eg"
+msgstr "megerősítésre vár"
+
+#: ../templates/classic/initial_data.py:16
+msgid "in-progress"
+msgstr "folyamatban"
+
+#: ../templates/classic/initial_data.py:17
+msgid "testing"
+msgstr "tesztelés"
+
+#: ../templates/classic/initial_data.py:18
+msgid "done-cbb"
+msgstr "elkészült-lehetne jobb"
+
+#: ../templates/classic/initial_data.py:19
+msgid "resolved"
+msgstr "megoldva"
+
+#: ../templates/minimal/html/home.html:2
+msgid "Tracker home - ${tracker}"
+msgstr "Hibakövető - ${tracker}"
+
+#: ../templates/minimal/html/home.html:4
+msgid "Tracker home"
+msgstr "Hibakövető"
+
+#: ../templates/minimal/html/home.html:16
+msgid "Please select from one of the menu options on the left."
+msgstr "Kérem válasszon a bal oldali menüből."
+
+#: ../templates/minimal/html/home.html:19
+msgid "Please log in or register."
+msgstr "Jelentkezzen be vagy regisztráljon."
diff --git a/locale/it.po b/locale/it.po
new file mode 100644 (file)
index 0000000..6d08eb1
--- /dev/null
@@ -0,0 +1,3043 @@
+# Italian message file for Roundup Issue Tracker
+# Marco Ghidinelli <marco.ghidinelli@ing.unibs.it>, 2007
+#
+# $Id: it.po,v 1.2 2007-08-24 05:31:13 a1s Exp $
+#
+# roundup.pot revision 1.22
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: roundup cvs\n"
+"Report-Msgid-Bugs-To: roundup-devel@lists.sourceforge.net\n"
+"POT-Creation-Date: 2006-12-18 13:36+0200\n"
+"PO-Revision-Date: 2007-07-17 11:05+0200\n"
+"Last-Translator: Marco Ghidinelli <marco.ghidinelli@ing.unibs.it>\n"
+"Language-Team: italian <it@li.org>\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:1052 ../roundup/admin.py:85:981 :1030:1052
+#: ../roundup/admin.py:85 ../roundup/admin.py:981 ../roundup/admin.py:1030
+#: ../roundup/admin.py:1052
+#, python-format
+msgid "no such class \"%(classname)s\""
+msgstr "classe \"%(classname)s\" mancante"
+
+# ../roundup/admin.py:95 ../roundup/admin.py:99 ../roundup/admin.py:95:99
+#: ../roundup/admin.py:95 ../roundup/admin.py:99
+#, python-format
+msgid "argument \"%(arg)s\" not propname=value"
+msgstr "argomento \"%(arg)s\" non nel formato nome=valore"
+
+#: ../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"
+" -V                -- be verbose when importing\n"
+" -v                -- report Roundup and Python versions (and quit)\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:140
+msgid "Commands:"
+msgstr "Comandi:"
+
+#: ../roundup/admin.py:147
+msgid ""
+"Commands may be abbreviated as long as the abbreviation\n"
+"matches only one command, e.g. l == li == lis == list."
+msgstr ""
+"I comandi possono essere abbreviati finchè l'abbreviazione rimane univoca\n"
+"es: l == li == lis == list."
+
+#: ../roundup/admin.py:177
+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:240
+#, python-format
+msgid "%s:"
+msgstr "%s:"
+
+#: ../roundup/admin.py:245
+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:268
+#, python-format
+msgid "Sorry, no help for \"%(topic)s\""
+msgstr "Nessun aiuto per \"%(topic)s\""
+
+# ../roundup/admin.py:340 ../roundup/admin.py:396 ../roundup/admin.py:340:396
+#: ../roundup/admin.py:340 ../roundup/admin.py:396
+msgid "Templates:"
+msgstr "Modelli predefiniti:"
+
+# ../roundup/admin.py:343 ../roundup/admin.py:407 ../roundup/admin.py:343:407
+#: ../roundup/admin.py:343 ../roundup/admin.py:407
+msgid "Back ends:"
+msgstr "Back ends:"
+
+#: ../roundup/admin.py:346
+msgid ""
+"Usage: install [template [backend [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 and backend may be specified on the command-line\n"
+"        as arguments, in that order.\n"
+"\n"
+"        Command line arguments following the backend allows you to\n"
+"        pass initial values 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:1243 ../roundup/admin.py:369:466 :1020:1042 :1072:1171
+# :1243 :527:606 :656:714 :735:763 :834:901 :972
+#: ../roundup/admin.py:369 ../roundup/admin.py:466 ../roundup/admin.py:527
+#: ../roundup/admin.py:606 ../roundup/admin.py:656 ../roundup/admin.py:714
+#: ../roundup/admin.py:735 ../roundup/admin.py:763 ../roundup/admin.py:834
+#: ../roundup/admin.py:901 ../roundup/admin.py:972 ../roundup/admin.py:1020
+#: ../roundup/admin.py:1042 ../roundup/admin.py:1072 ../roundup/admin.py:1171
+#: ../roundup/admin.py:1243
+msgid "Not enough arguments supplied"
+msgstr "Non sono stati forniti abbastanza argomenti"
+
+#: ../roundup/admin.py:375
+#, python-format
+msgid "Instance home parent directory \"%(parent)s\" does not exist"
+msgstr "la directory radice dell'istanza \"%(parent)s\" non esiste"
+
+#: ../roundup/admin.py:383
+#, 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 ""
+"ATTENZIONE: È presente un tracker nella directory \"%(tracker_home)s\"!\n"
+"Se verrà reinstallata, tutti i dati precedentemente salvati andranno persi\n"
+"Cancellare la directory specificata? Y/N: "
+
+#: ../roundup/admin.py:398
+msgid "Select template [classic]: "
+msgstr "Seleziona il modello predefinito [classic]: "
+
+#: ../roundup/admin.py:409
+msgid "Select backend [anydbm]: "
+msgstr "Seleziona il backend [anydbm]: "
+
+#: ../roundup/admin.py:419
+#, python-format
+msgid "Error in configuration settings: \"%s\""
+msgstr "Erorre nei settaggi di configurazione: \"%s\""
+
+#: ../roundup/admin.py:428
+#, python-format
+msgid ""
+"\n"
+"---------------------------------------------------------------------------\n"
+" You should now edit the tracker configuration file:\n"
+"   %(config_file)s"
+msgstr ""
+
+#: ../roundup/admin.py:438
+msgid " ... at a minimum, you must set following options:"
+msgstr " ... devono essere configurate almeno le seguenti opzioni:"
+
+#: ../roundup/admin.py:443
+#, 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:461
+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:471
+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:485
+msgid "Admin Password: "
+msgstr "Password dell'amministratore"
+
+#: ../roundup/admin.py:486
+msgid "       Confirm: "
+msgstr "       Conferma: "
+
+#: ../roundup/admin.py:490
+msgid "Instance home does not exist"
+msgstr "La home dell'istanza non esiste"
+
+#: ../roundup/admin.py:494
+msgid "Instance has not been installed"
+msgstr "L'istanza non è stata installata"
+
+#: ../roundup/admin.py:499
+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:520
+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:560 ../roundup/admin.py:575 ../roundup/admin.py:560:575
+#: ../roundup/admin.py:560 ../roundup/admin.py:575
+#, python-format
+msgid "property %s is not of type Multilink or Link so -d flag does not apply."
+msgstr ""
+
+# ../roundup/admin.py:1054 ../roundup/admin.py:583:983 :1032:1054
+#: ../roundup/admin.py:583 ../roundup/admin.py:983 ../roundup/admin.py:1032
+#: ../roundup/admin.py:1054
+#, python-format
+msgid "no such %(classname)s node \"%(nodeid)s\""
+msgstr ""
+
+#: ../roundup/admin.py:585
+#, python-format
+msgid "no such %(classname)s property \"%(propname)s\""
+msgstr ""
+
+#: ../roundup/admin.py:594
+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:648
+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:920 ../roundup/admin.py:701:854 :866:920
+#: ../roundup/admin.py:701 ../roundup/admin.py:854 ../roundup/admin.py:866
+#: ../roundup/admin.py:920
+#, python-format
+msgid "%(classname)s has no property \"%(propname)s\""
+msgstr "la classe %(classname)s non ha la proprietà \"%(propname)s\""
+
+#: ../roundup/admin.py:708
+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:723
+#, python-format
+msgid "%(key)s: %(value)s (key property)"
+msgstr "%(key)s %(value)s (chiave)"
+
+#: ../roundup/admin.py:725
+#, python-format
+msgid "%(key)s: %(value)s"
+msgstr "%(key)s:·%(value)s"
+
+#: ../roundup/admin.py:728
+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:752
+#, python-format
+msgid "%(key)s: %(value)r"
+msgstr "%(key)s:·%(value)r"
+
+#: ../roundup/admin.py:755
+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:782
+#, python-format
+msgid "%(propname)s (Password): "
+msgstr "%(propname)s·(Password):·"
+
+#: ../roundup/admin.py:784
+#, python-format
+msgid "   %(propname)s (Again): "
+msgstr "   %(propname)s (Ripeti password): "
+
+#: ../roundup/admin.py:786
+msgid "Sorry, try again..."
+msgstr "Mi dispiace, riprova..."
+
+#: ../roundup/admin.py:790
+#, python-format
+msgid "%(propname)s (%(proptype)s): "
+msgstr "%(propname)s (%(proptype)s): "
+
+#: ../roundup/admin.py:808
+#, python-format
+msgid "you must provide the \"%(propname)s\" property."
+msgstr "deve essere fornita la proprietà \"%(propname)s\"."
+
+#: ../roundup/admin.py:819
+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:832
+msgid "Too many arguments supplied"
+msgstr ""
+
+#: ../roundup/admin.py:868
+#, python-format
+msgid "%(nodeid)4s: %(value)s"
+msgstr ""
+
+#: ../roundup/admin.py:872
+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:916
+#, python-format
+msgid "\"%(spec)s\" not name:width"
+msgstr ""
+
+#: ../roundup/admin.py:966
+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:987
+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:1001
+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:1013
+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:1036
+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:1058
+msgid ""
+"Usage: export [[-]class[,class]] export_dir\n"
+"        Export the database to colon-separated-value files.\n"
+"        To exclude the files (e.g. for the msg or file class),\n"
+"        use the exporttables command.\n"
+"\n"
+"        Optionally limit the export to just the named classes\n"
+"        or exclude the named classes, if the 1st argument starts with '-'.\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:1136
+msgid ""
+"Usage: exporttables [[-]class[,class]] export_dir\n"
+"        Export the database to colon-separated-value files, excluding the\n"
+"        files below $TRACKER_HOME/db/files/ (which can be archived "
+"separately).\n"
+"        To include the files, use the export command.\n"
+"\n"
+"        Optionally limit the export to just the named classes\n"
+"        or exclude the named classes, if the 1st argument starts with '-'.\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:1151
+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:1225
+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:1253
+msgid "Invalid format"
+msgstr ""
+
+#: ../roundup/admin.py:1263
+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:1277
+#, python-format
+msgid "no such item \"%(designator)s\""
+msgstr ""
+
+#: ../roundup/admin.py:1287
+msgid ""
+"Usage: security [Role name]\n"
+"        Display the Permissions available to one or all Roles.\n"
+"        "
+msgstr ""
+
+#: ../roundup/admin.py:1295
+#, python-format
+msgid "No such Role \"%(role)s\""
+msgstr "Non è presente il ruolo \"%(role)s\""
+
+#: ../roundup/admin.py:1301
+#, python-format
+msgid "New Web users get the Roles \"%(role)s\""
+msgstr "I nuovi utenti Web otterranno i ruoli \"%(role)s\""
+
+#: ../roundup/admin.py:1303
+#, python-format
+msgid "New Web users get the Role \"%(role)s\""
+msgstr "I nuovi utenti Web otterranno il ruolo \"%(role)s)\""
+
+#: ../roundup/admin.py:1306
+#, python-format
+msgid "New Email users get the Roles \"%(role)s\""
+msgstr "I nuovi utenti Email otterranno i ruoli \"%(role)s)\""
+
+#: ../roundup/admin.py:1308
+#, python-format
+msgid "New Email users get the Role \"%(role)s\""
+msgstr "I nuovi utenti Email otterranno il ruolo \"%(role)s\""
+
+#: ../roundup/admin.py:1311
+#, python-format
+msgid "Role \"%(name)s\":"
+msgstr "Ruolo \"%(name)s\":"
+
+#: ../roundup/admin.py:1316
+#, python-format
+msgid " %(description)s (%(name)s for \"%(klass)s\": %(properties)s only)"
+msgstr ""
+
+#: ../roundup/admin.py:1319
+#, python-format
+msgid " %(description)s (%(name)s for \"%(klass)s\" only)"
+msgstr ""
+
+#: ../roundup/admin.py:1322
+#, python-format
+msgid " %(description)s (%(name)s)"
+msgstr ""
+
+#: ../roundup/admin.py:1351
+#, python-format
+msgid "Unknown command \"%(command)s\" (\"help commands\" for a list)"
+msgstr ""
+
+#: ../roundup/admin.py:1357
+#, python-format
+msgid "Multiple commands match \"%(command)s\": %(list)s"
+msgstr ""
+
+#: ../roundup/admin.py:1364
+msgid "Enter tracker home: "
+msgstr ""
+
+# ../roundup/admin.py:1371:1377 :1397
+#: ../roundup/admin.py:1371 ../roundup/admin.py:1377 ../roundup/admin.py:1397
+#, python-format
+msgid "Error: %(message)s"
+msgstr ""
+
+#: ../roundup/admin.py:1385
+#, python-format
+msgid "Error: Couldn't open tracker: %(message)s"
+msgstr ""
+
+#: ../roundup/admin.py:1410
+#, python-format
+msgid ""
+"Roundup %s ready for input.\n"
+"Type \"help\" for help."
+msgstr ""
+
+#: ../roundup/admin.py:1415
+msgid "Note: command history and editing not available"
+msgstr ""
+
+#: ../roundup/admin.py:1419
+msgid "roundup> "
+msgstr ""
+
+#: ../roundup/admin.py:1421
+msgid "exit..."
+msgstr ""
+
+#: ../roundup/admin.py:1431
+msgid "There are unsaved changes. Commit them (y/N)? "
+msgstr ""
+
+#: ../roundup/backends/back_anydbm.py:2000
+#, python-format
+msgid "WARNING: invalid date tuple %r"
+msgstr ""
+
+#: ../roundup/backends/rdbms_common.py:1442
+msgid "create"
+msgstr "crea"
+
+#: ../roundup/backends/rdbms_common.py:1608
+msgid "unlink"
+msgstr "collega"
+
+#: ../roundup/backends/rdbms_common.py:1612
+msgid "link"
+msgstr "scollega"
+
+#: ../roundup/backends/rdbms_common.py:1732
+msgid "set"
+msgstr "assegna"
+
+#: ../roundup/backends/rdbms_common.py:1756
+msgid "retired"
+msgstr "ritira"
+
+#: ../roundup/backends/rdbms_common.py:1786
+msgid "restored"
+msgstr "ripristina"
+
+#: ../roundup/cgi/actions.py:58
+#, python-format
+msgid "You do not have permission to %(action)s the %(classname)s class."
+msgstr "Non hai i permessi per %{action) la classe %(classname)."
+
+#: ../roundup/cgi/actions.py:89
+msgid "No type specified"
+msgstr "Non hai specificato alcun tipo"
+
+#: ../roundup/cgi/actions.py:91
+msgid "No ID entered"
+msgstr "Non hai fornito alcun ID"
+
+#: ../roundup/cgi/actions.py:97
+#, python-format
+msgid "\"%(input)s\" is not an ID (%(classname)s ID required)"
+msgstr "\"%(input)\" non è un ID (%(ID della %(classname) è obbligatorio"
+
+#: ../roundup/cgi/actions.py:117
+msgid "You may not retire the admin or anonymous user"
+msgstr "Non è possibile ritirare l'utente amministratore o l'utente anonimo"
+
+#: ../roundup/cgi/actions.py:124
+#, python-format
+msgid "%(classname)s %(itemid)s has been retired"
+msgstr "%(classname)s %(itemid)s è stato ritirato"
+
+# ../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 "Non hai il permesso di modificare delle query"
+
+# ../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 "Non hai il permesso di archiviare delle query"
+
+#: ../roundup/cgi/actions.py:298
+#, python-format
+msgid "Not enough values on line %(line)s"
+msgstr "Non abbastanza valori alla riga %(line)"
+
+#: ../roundup/cgi/actions.py:345
+msgid "Items edited OK"
+msgstr "Item modificato correttamente"
+
+#: ../roundup/cgi/actions.py:405
+#, python-format
+msgid "%(class)s %(id)s %(properties)s edited ok"
+msgstr "%(class)s %(id)s %(properties)s modificata correttamente"
+
+#: ../roundup/cgi/actions.py:408
+#, python-format
+msgid "%(class)s %(id)s - nothing changed"
+msgstr "%(class)s %(id)s - nessuna modifica"
+
+#: ../roundup/cgi/actions.py:420
+#, python-format
+msgid "%(class)s %(id)s created"
+msgstr "%(class)s %(id)s creata"
+
+#: ../roundup/cgi/actions.py:452
+#, python-format
+msgid "You do not have permission to edit %(class)s"
+msgstr "Non hai i permessi per modificare i $(class)s"
+
+#: ../roundup/cgi/actions.py:464
+#, python-format
+msgid "You do not have permission to create %(class)s"
+msgstr "Non hai il permesso per creare $(class)s"
+
+#: ../roundup/cgi/actions.py:488
+msgid "You do not have permission to edit user roles"
+msgstr "Non hai i permessi per modificare l ruoli dell'utente"
+
+#: ../roundup/cgi/actions.py:538
+#, 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 ""
+"Errore di Modifica: qualcun'altro ha modificato questo %s (%s). Visualizza "
+"<a target=\"new\" href=\"%s%s\">le sue modifiche</a> in una nuova finestra."
+
+#: ../roundup/cgi/actions.py:566
+#, python-format
+msgid "Edit Error: %s"
+msgstr "Errore di modifica: %s"
+
+# ../roundup/cgi/actions.py:597:608 :779:798
+#: ../roundup/cgi/actions.py:597 ../roundup/cgi/actions.py:608
+#: ../roundup/cgi/actions.py:779 ../roundup/cgi/actions.py:798
+#, python-format
+msgid "Error: %s"
+msgstr "Errore: %s"
+
+#: ../roundup/cgi/actions.py:634
+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 invalido!\n"
+"(un bug di Mozilla può causare la erronea presenza di questo messaggio, (per "
+"favore controlla la tua email)"
+
+#: ../roundup/cgi/actions.py:676
+#, python-format
+msgid "Password reset and email sent to %s"
+msgstr "Password modificata e mandata in email a %s"
+
+#: ../roundup/cgi/actions.py:685
+msgid "Unknown username"
+msgstr "Nome Utente sconosciuto"
+
+#: ../roundup/cgi/actions.py:693
+msgid "Unknown email address"
+msgstr "Indirizzo di email sconosciuto"
+
+#: ../roundup/cgi/actions.py:698
+msgid "You need to specify a username or address"
+msgstr "È necessario specificare un Nome Utente o un indirizzo email"
+
+#: ../roundup/cgi/actions.py:723
+#, python-format
+msgid "Email sent to %s"
+msgstr "Email inviata a %s"
+
+#: ../roundup/cgi/actions.py:742
+msgid "You are now registered, welcome!"
+msgstr "Ora sei un utente registrato, benvenuto!"
+
+#: ../roundup/cgi/actions.py:787
+msgid "It is not permitted to supply roles at registration."
+msgstr "Non è permesso fornire ruoli in fase di registrazione."
+
+#: ../roundup/cgi/actions.py:879
+msgid "You are logged out"
+msgstr "Disconnesso"
+
+#: ../roundup/cgi/actions.py:896
+msgid "Username required"
+msgstr "È richiesto il Nome Utente"
+
+# ../roundup/cgi/actions.py:931:935
+#: ../roundup/cgi/actions.py:931 ../roundup/cgi/actions.py:935
+msgid "Invalid login"
+msgstr "Login invalida"
+
+#: ../roundup/cgi/actions.py:941
+msgid "You do not have permission to login"
+msgstr "Non hai il permesso per eseguire la login"
+
+#: ../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>Errore del sistema di Templating</h1>\n"
+"<p><b>%(exc_type)s</b>: %(exc_value)s</p>\n"
+"<p class=\"help\">Debugging information follows</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>Sto cercando \"%(name)s\", percorso corrente:<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 "È occorso un problema nel tuo template"
+
+#: ../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>Mentre veniva valutato la espressione %(info)r alla riga %(line)d\n"
+"<table class=\"otherinfo\" style=\"font-size: 90%%\">\n"
+" <tr><th colspan=\"2\" class=\"header\">Variabile Corrente:</th></tr>\n"
+" %(globals)s\n"
+" %(locals)s\n"
+"</table></li>\n"
+
+#: ../roundup/cgi/cgitb.py:103
+msgid "Full traceback:"
+msgstr "Traceback completo:"
+
+#: ../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 problema è occorso mentre veniva eseguito uno script Python. Questa è "
+"la sequenza di chiamate di sistema che hanno condotto a questo errore, con "
+"la più recente (la più interna) prima. Le eccezioni sono:"
+
+#: ../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 "in <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>indefinito</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>È accaduto un errore</title></head>\n"
+"<body><h1>È accaduto un errore</h1>\n"
+"<p>È accaduto un errore mentre veniva processata la richiesta.\n"
+"La notifica del problema è stata notificata al manutentore del tracker.</p>\n"
+"</body></html>"
+
+#: ../roundup/cgi/client.py:326
+msgid "Form Error: "
+msgstr "Errore nella Form: "
+
+#: ../roundup/cgi/client.py:381
+#, python-format
+msgid "Unrecognized charset: %r"
+msgstr "Codice di carattere sconosciuto: %r"
+
+#: ../roundup/cgi/client.py:509
+msgid "Anonymous users are not allowed to use the web interface"
+msgstr ""
+"Gli utenti anonimi non hanno il permesso di utilizzare l'interfaccia web"
+
+#: ../roundup/cgi/client.py:664
+msgid "You are not allowed to view this file."
+msgstr "Non si dispone dei permessi per visualizzare questo file."
+
+#: ../roundup/cgi/client.py:758
+#, python-format
+msgid "%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n"
+msgstr "%(starttag)sTempo trascorso: %(seconds)fs%(endtad)s\n"
+
+#: ../roundup/cgi/client.py:762
+#, 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 \"%(entry)s\" not a designator"
+msgstr "il collegamento \"%(key)s\" valore \"%(entry)s\" non è il designatore"
+
+#: ../roundup/cgi/form_parser.py:301
+#, python-format
+msgid "%(class)s %(property)s is not a link or multilink property"
+msgstr ""
+"%(class)s %(property)s non è una proprietà di tipo collegamento o "
+"multicollegamento"
+
+#: ../roundup/cgi/form_parser.py:313
+#, python-format
+msgid ""
+"The form action claims to require property \"%(property)s\" which doesn't "
+"exist"
+msgstr ""
+"L'azione assiociata alla form richiede la proprietà \"%(property)s\" che non "
+"esiste"
+
+#: ../roundup/cgi/form_parser.py:335
+#, python-format
+msgid ""
+"You have submitted a %(action)s action for the property \"%(property)s\" "
+"which doesn't exist"
+msgstr ""
+"È stata inserito una azione %(action)s per la proprietà \"%(property)s\" che "
+"non esiste"
+
+# ../roundup/cgi/form_parser.py:354:380
+#: ../roundup/cgi/form_parser.py:354 ../roundup/cgi/form_parser.py:380
+#, python-format
+msgid "You have submitted more than one value for the %s property"
+msgstr "È stata inserito più di un valore per la proprietà %s"
+
+# ../roundup/cgi/form_parser.py:377:383
+#: ../roundup/cgi/form_parser.py:377 ../roundup/cgi/form_parser.py:383
+msgid "Password and confirmation text do not match"
+msgstr "La password e il testo di conferma non coincidono"
+
+#: ../roundup/cgi/form_parser.py:418
+#, python-format
+msgid "property \"%(propname)s\": \"%(value)s\" not currently in list"
+msgstr ""
+"la proprietà \"%(propname)s\": \"%(value)s\" non è al momento nella lista"
+
+#: ../roundup/cgi/form_parser.py:551
+#, python-format
+msgid "Required %(class)s property %(property)s not supplied"
+msgid_plural "Required %(class)s properties %(property)s not supplied"
+msgstr[0] "La proprietà %(class)s %(property)s non è stata fornita"
+msgstr[1] "Le proprietà %(class)s %(property)s non sono state fornite"
+
+#: ../roundup/cgi/form_parser.py:574
+msgid "File is empty"
+msgstr "Il file è vuoto"
+
+#: ../roundup/cgi/templating.py:73
+#, python-format
+msgid "You are not allowed to %(action)s items of class %(class)s"
+msgstr "Non si dispone dei permessi per %(action)s item della classe %(class)s"
+
+#: ../roundup/cgi/templating.py:645
+msgid "(list)"
+msgstr "(elenco)"
+
+#: ../roundup/cgi/templating.py:714
+msgid "Submit New Entry"
+msgstr "Crea Nuovo"
+
+# ../roundup/cgi/templating.py:728:862 :1269:1298 :1318:1364 :1387:1423
+# :1460:1513 :1530:1614 :1634:1652 :1684:1694 :1746:1935
+#: ../roundup/cgi/templating.py:728 ../roundup/cgi/templating.py:862
+#: ../roundup/cgi/templating.py:1269 ../roundup/cgi/templating.py:1298
+#: ../roundup/cgi/templating.py:1318 ../roundup/cgi/templating.py:1364
+#: ../roundup/cgi/templating.py:1387 ../roundup/cgi/templating.py:1423
+#: ../roundup/cgi/templating.py:1460 ../roundup/cgi/templating.py:1513
+#: ../roundup/cgi/templating.py:1530 ../roundup/cgi/templating.py:1614
+#: ../roundup/cgi/templating.py:1634 ../roundup/cgi/templating.py:1652
+#: ../roundup/cgi/templating.py:1684 ../roundup/cgi/templating.py:1694
+#: ../roundup/cgi/templating.py:1746 ../roundup/cgi/templating.py:1935
+msgid "[hidden]"
+msgstr "[nascosto]"
+
+#: ../roundup/cgi/templating.py:729
+msgid "New node - no history"
+msgstr "Nuovo nodo - nessuno storico"
+
+#: ../roundup/cgi/templating.py:844
+msgid "Submit Changes"
+msgstr "Inserisci Modifiche"
+
+#: ../roundup/cgi/templating.py:926
+msgid "<em>The indicated property no longer exists</em>"
+msgstr "<em>La caratteristica indicata non esiste</em>"
+
+#: ../roundup/cgi/templating.py:927
+#, python-format
+msgid "<em>%s: %s</em>\n"
+msgstr "<em>%s: %s</em>\n"
+
+#: ../roundup/cgi/templating.py:940
+#, python-format
+msgid "The linked class %(classname)s no longer exists"
+msgstr "La classe collegata %(classname)s non esiste più"
+
+# ../roundup/cgi/templating.py:973:997
+#: ../roundup/cgi/templating.py:973 ../roundup/cgi/templating.py:997
+msgid "<strike>The linked node no longer exists</strike>"
+msgstr "<strike>Il Nodo collegato non esiste più</strike>"
+
+#: ../roundup/cgi/templating.py:1050
+#, python-format
+msgid "%s: (no value)"
+msgstr "%s: (nessun valore)"
+
+#: ../roundup/cgi/templating.py:1062
+msgid ""
+"<strong><em>This event is not handled by the history display!</em></strong>"
+msgstr ""
+"<strong><em>Questo evento non è gestito dal visualizzatore dello storico!</"
+"em></strong>"
+
+#: ../roundup/cgi/templating.py:1074
+msgid "<tr><td colspan=4><strong>Note:</strong></td></tr>"
+msgstr "<tr><td colspan=4><strong>Note:</strong></td></tr>"
+
+#: ../roundup/cgi/templating.py:1083
+msgid "History"
+msgstr "Storico"
+
+#: ../roundup/cgi/templating.py:1085
+msgid "<th>Date</th>"
+msgstr "<th>Data</th>"
+
+#: ../roundup/cgi/templating.py:1086
+msgid "<th>User</th>"
+msgstr "<th>Utente</th>"
+
+#: ../roundup/cgi/templating.py:1087
+msgid "<th>Action</th>"
+msgstr "<th>Azione</th>"
+
+#: ../roundup/cgi/templating.py:1088
+msgid "<th>Args</th>"
+msgstr "<th>Argomenti</th>"
+
+#: ../roundup/cgi/templating.py:1130
+#, python-format
+msgid "Copy of %(class)s %(id)s"
+msgstr "Copia di %(class)s %(id)s"
+
+#: ../roundup/cgi/templating.py:1391
+msgid "*encrypted*"
+msgstr "*crittato*"
+
+# ../roundup/cgi/templating.py:1491 ../roundup/cgi/templating.py:1039:1464
+# :1485:1491
+#: ../roundup/cgi/templating.py:1464 ../roundup/cgi/templating.py:1485
+#: ../roundup/cgi/templating.py:1491 ../roundup/cgi/templating.py:1039
+msgid "No"
+msgstr "No"
+
+# ../roundup/cgi/templating.py:1488 ../roundup/cgi/templating.py:1039:1464
+# :1483:1488
+#: ../roundup/cgi/templating.py:1464 ../roundup/cgi/templating.py:1483
+#: ../roundup/cgi/templating.py:1488 ../roundup/cgi/templating.py:1039
+msgid "Yes"
+msgstr "Sì"
+
+#: ../roundup/cgi/templating.py:1577
+msgid ""
+"default value for DateHTMLProperty must be either DateHTMLProperty or string "
+"date representation."
+msgstr ""
+"Il valore predefinito per DateHTMLProperty deve essere  DateHTMLProperty "
+"oppure una stringa rappresentante una data."
+
+#: ../roundup/cgi/templating.py:1737
+#, python-format
+msgid "Attempt to look up %(attr)s on a missing value"
+msgstr "Tentativo di visualizzare %(attr)s con un valore mancante"
+
+#: ../roundup/cgi/templating.py:1810
+#, python-format
+msgid "<option %svalue=\"-1\">- no selection -</option>"
+msgstr "<option %svalue=\"-1\">- nessuna selezione -</option>"
+
+#: ../roundup/date.py:301
+msgid ""
+"Not a date spec: \"yyyy-mm-dd\", \"mm-dd\", \"HH:MM\", \"HH:MM:SS\" or "
+"\"yyyy-mm-dd.HH:MM:SS.SSS\""
+msgstr ""
+"Non specifica una data: \"yyyy-mm-dd\", \"mm-dd\", \"HH:MM\", \"HH:MM:SS\" o "
+"\"yyyy-mm-dd.HH:MM:SS.SSS\""
+
+#: ../roundup/date.py:363
+#, 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 non specifica una data / momento \"yyyy-mm-dd\", \"mm-dd\", \"HH:MM\", "
+"\"HH:MM:SS\" o \"yyyy-mm-dd.HH:MM:SS.SSS\""
+
+#: ../roundup/date.py:662
+msgid ""
+"Not an interval spec: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS] [date spec]"
+msgstr ""
+"Non specifica un intervallo: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS] [date "
+"spec]"
+
+#: ../roundup/date.py:681
+msgid "Not an interval spec: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS]"
+msgstr "Non specifica un intervallo: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS]"
+
+#: ../roundup/date.py:818
+#, python-format
+msgid "%(number)s year"
+msgid_plural "%(number)s years"
+msgstr[0] "%(number)s anno"
+msgstr[1] "%(numeber)s anni"
+
+#: ../roundup/date.py:822
+#, python-format
+msgid "%(number)s month"
+msgid_plural "%(number)s months"
+msgstr[0] "%(number)s mese"
+msgstr[1] "%(number)s mesi"
+
+#: ../roundup/date.py:826
+#, python-format
+msgid "%(number)s week"
+msgid_plural "%(number)s weeks"
+msgstr[0] "%(number)s settimana"
+msgstr[1] "%(number)s settimane"
+
+#: ../roundup/date.py:830
+#, python-format
+msgid "%(number)s day"
+msgid_plural "%(number)s days"
+msgstr[0] "%(number)s giorno"
+msgstr[1] "%(number)s giorni"
+
+#: ../roundup/date.py:834
+msgid "tomorrow"
+msgstr "domani"
+
+#: ../roundup/date.py:836
+msgid "yesterday"
+msgstr "ieri"
+
+#: ../roundup/date.py:839
+#, python-format
+msgid "%(number)s hour"
+msgid_plural "%(number)s hours"
+msgstr[0] "%(number)s ora"
+msgstr[1] "%(number)s ore"
+
+#: ../roundup/date.py:843
+msgid "an hour"
+msgstr "un'ora"
+
+#: ../roundup/date.py:845
+msgid "1 1/2 hours"
+msgstr "un'ora e mezza"
+
+#: ../roundup/date.py:847
+#, python-format
+msgid "1 %(number)s/4 hours"
+msgid_plural "1 %(number)s/4 hours"
+msgstr[0] "%(number)s quarto d'ora"
+msgstr[1] "%(number)s quarti d'ora"
+
+#: ../roundup/date.py:851
+msgid "in a moment"
+msgstr "in un momento"
+
+#: ../roundup/date.py:853
+msgid "just now"
+msgstr "proprio ora"
+
+#: ../roundup/date.py:856
+msgid "1 minute"
+msgstr "un minuto"
+
+#: ../roundup/date.py:859
+#, python-format
+msgid "%(number)s minute"
+msgid_plural "%(number)s minutes"
+msgstr[0] "%(number)s minuto"
+msgstr[1] "%(number)s minuti"
+
+#: ../roundup/date.py:862
+msgid "1/2 an hour"
+msgstr "mezzora"
+
+#: ../roundup/date.py:864
+#, python-format
+msgid "%(number)s/4 hour"
+msgid_plural "%(number)s/4 hours"
+msgstr[0] "%(number)s ora"
+msgstr[1] "%(number)s ore"
+
+#: ../roundup/date.py:868
+#, python-format
+msgid "%s ago"
+msgstr "%s fa"
+
+#: ../roundup/date.py:870
+#, python-format
+msgid "in %s"
+msgstr "in %s"
+
+#: ../roundup/init.py:134
+#, python-format
+msgid ""
+"WARNING: directory '%s'\n"
+"\tcontains old-style template - ignored"
+msgstr ""
+"ATTENZIONE: La directory '%s'\n"
+"\tcontene un template old-style - ignorato"
+
+#: ../roundup/mailgw.py:583
+msgid ""
+"\n"
+"Emails to Roundup trackers must include a Subject: line!\n"
+msgstr ""
+"\n"
+"Le Email al tracker Roundup devono includere il campo Oggetto: \n"
+
+#: ../roundup/mailgw.py:673
+#, python-format
+msgid ""
+"\n"
+"The message you sent to roundup did not contain a properly formed subject\n"
+"line. The subject must contain a class name or designator to indicate the\n"
+"'topic' of the message. For example:\n"
+"    Subject: [issue] This is a new issue\n"
+"      - this will create a new issue in the tracker with the title 'This is\n"
+"        a new issue'.\n"
+"    Subject: [issue1234] This is a followup to issue 1234\n"
+"      - this will append the message's contents to the existing issue 1234\n"
+"        in the tracker.\n"
+"\n"
+"Subject was: '%(subject)s'\n"
+msgstr ""
+"\n"
+"Il messaggio che hai mandato a Roundup non contiene un campo \"Subject: "
+"\" (Oggetto) ben formato. L'oggetto deve contenere un nome di una classe o "
+"un designatore per indicare l'argomento del messaggio. Per esempio:\n"
+"    Subject: [issue] Questa è una nuova richiesta\n"
+"      - crea una nuova richiesta titolata: 'Questa è una nuova richiesta'.\n"
+"    Subject: [issue1234] Questa è una risposta alla richiesta 1234\n"
+"      - aggiunge un nuovo messaggio alla richiesta 1234 esistente\n"
+"\n"
+"Il campo \"Subject: \" (Oggetto) era: '%(subject)s'\n"
+
+#: ../roundup/mailgw.py:704
+#, python-format
+msgid ""
+"\n"
+"The class name you identified in the subject line (\"%(classname)s\") does "
+"not exist in the\n"
+"database.\n"
+"\n"
+"Valid class names are: %(validname)s\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"\n"
+"La classe che hai identificato nel Subject: (\"%(classname)s\") non esiste "
+"nel database.\n"
+"\n"
+"Classi valide sono: %(validname)s\n"
+"Il campo Subject: conteneva il valore \"%(subject)s\"\n"
+
+#: ../roundup/mailgw.py:739
+#, python-format
+msgid ""
+"\n"
+"I cannot match your message to a node in the database - you need to either\n"
+"supply a full designator (with number, eg \"[issue123]\" or keep the\n"
+"previous subject title intact so I can match that.\n"
+"\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"\n"
+"Non è stato possibile far corrispondere il tuo messaggio con un nodo del "
+"database - devi specificare nel Subject (Oggetto) un designatore corretto "
+"(con un numero, per esempio [issue1234]) o alternativamente tenere il "
+"Subject precedente intatto.\n"
+"Il campo Subject: conteneva il valore \"%(subject)s\"\n"
+
+#: ../roundup/mailgw.py:772
+#, python-format
+msgid ""
+"\n"
+"The node specified by the designator in the subject of your message\n"
+"(\"%(nodeid)s\") does not exist.\n"
+"\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"\n"
+"Il nodo specificato dal desigantore nel Subject del tuo messaggio (\"%"
+"(nodeid)s\") non esiste.\n"
+"\n"
+"Il campo Subject: conteneva il valore \"%(subject)s\"\n"
+
+#: ../roundup/mailgw.py:800
+#, python-format
+msgid ""
+"\n"
+"The mail gateway is not properly set up. Please contact\n"
+"%(mailadmin)s and have them fix the incorrect class specified as:\n"
+"  %(current_class)s\n"
+msgstr ""
+"\n"
+"Il gateway mail non è configurato correttamente. Per favore contatta\n"
+"%(mailadmin)s e segnala che la classe specificata come:\n"
+"  %(current_class)s è scorretta.\n"
+
+#: ../roundup/mailgw.py:823
+#, python-format
+msgid ""
+"\n"
+"The mail gateway is not properly set up. Please contact\n"
+"%(mailadmin)s and have them fix the incorrect properties:\n"
+"  %(errors)s\n"
+msgstr ""
+"\n"
+"il gateway mail non è configurato correttamente. Per favore contatta\n"
+"%(mailadmin)s e segnala che la proprietà scorretta:\n"
+"  %(errors)s\n"
+
+#: ../roundup/mailgw.py:853
+#, python-format
+msgid ""
+"\n"
+"You are not a registered user.\n"
+"\n"
+"Unknown address: %(from_address)s\n"
+msgstr ""
+"\n"
+"Non sei un utente registrato.\n"
+"\n"
+"Indirizzo sconosciuto: %(from_address)s\n"
+
+#: ../roundup/mailgw.py:861
+msgid "You are not permitted to access this tracker."
+msgstr "Non si dispone di permessi sufficienti per accedere a questo tracker"
+
+#: ../roundup/mailgw.py:868
+#, python-format
+msgid "You are not permitted to edit %(classname)s."
+msgstr "Non si dispone dei permessi sufficienti per modificare %(classname)s"
+
+#: ../roundup/mailgw.py:872
+#, python-format
+msgid "You are not permitted to create %(classname)s."
+msgstr "Non si dispone dei permessi sufficienti per creare %(classname)s"
+
+#: ../roundup/mailgw.py:919
+#, python-format
+msgid ""
+"\n"
+"There were problems handling your subject line argument list:\n"
+"- %(errors)s\n"
+"\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"\n"
+"Si è verificato un problema nel gestire la lista degli argomenti nel Subject "
+"del messaggio:- %(errors)s\n"
+"\n"
+"Il Subject era: \"%(subject)s\"\n"
+
+#: ../roundup/mailgw.py:947
+msgid ""
+"\n"
+"Roundup requires the submission to be plain text. The message parser could\n"
+"not find a text/plain part to use.\n"
+msgstr ""
+"\n"
+"Roundup richiede che i valori immessi siano in formato testo. Il parser dei "
+"messaggi non ha trovato alcuna parte text/plain da utilizzare.\n"
+
+#: ../roundup/mailgw.py:969
+msgid "You are not permitted to create files."
+msgstr "Non si dispone dei permessi necessari a creare file."
+
+#: ../roundup/mailgw.py:983
+#, python-format
+msgid "You are not permitted to add files to %(classname)s."
+msgstr ""
+"Non si dispone dei permessi necessari per aggiungere file a %(classname)s"
+
+#: ../roundup/mailgw.py:1001
+msgid "You are not permitted to create messages."
+msgstr "Non si disponde dei permessi necessari per creare messaggi."
+
+#: ../roundup/mailgw.py:1009
+#, python-format
+msgid ""
+"\n"
+"Mail message was rejected by a detector.\n"
+"%(error)s\n"
+msgstr ""
+"\n"
+"La Mail è stata rifiutata da un detector.\n"
+"%(error)s\n"
+
+#: ../roundup/mailgw.py:1017
+#, python-format
+msgid "You are not permitted to add messages to %(classname)s."
+msgstr "Non si dispone dei permessi per aggiungere messaggi a %(classname)s"
+
+#: ../roundup/mailgw.py:1044
+#, python-format
+msgid "You are not permitted to edit property %(prop)s of class %(classname)s."
+msgstr ""
+"Non si dispone dei permessi necessari per modificare la proprietà %(prop)s "
+"della classe %(classname)s"
+
+#: ../roundup/mailgw.py:1052
+#, python-format
+msgid ""
+"\n"
+"There was a problem with the message you sent:\n"
+"   %(message)s\n"
+msgstr ""
+"\n"
+"Si è verificato un problema con il messaggio che hai inviato:\n"
+"   %(message)s\n"
+
+#: ../roundup/mailgw.py:1074
+msgid "not of form [arg=value,value,...;arg=value,value,...]"
+msgstr "Non nel formato [arg=valore,valore,...;arg=valore,valore,...]"
+
+#: ../roundup/roundupdb.py:146
+msgid "files"
+msgstr "file"
+
+#: ../roundup/roundupdb.py:146
+msgid "messages"
+msgstr "messaggi"
+
+#: ../roundup/roundupdb.py:146
+msgid "nosy"
+msgstr "ficcanaso"
+
+#: ../roundup/roundupdb.py:146
+msgid "superseder"
+msgstr "soprassiede"
+
+#: ../roundup/roundupdb.py:146
+msgid "title"
+msgstr "titolo"
+
+#: ../roundup/roundupdb.py:147
+msgid "assignedto"
+msgstr "assegnato a"
+
+#: ../roundup/roundupdb.py:147
+msgid "priority"
+msgstr "priorità"
+
+#: ../roundup/roundupdb.py:147
+msgid "status"
+msgstr "stato"
+
+#: ../roundup/roundupdb.py:147
+msgid "topic"
+msgstr "argomento"
+
+#: ../roundup/roundupdb.py:150
+msgid "activity"
+msgstr "attività"
+
+#. following properties are common for all hyperdb classes
+#. they are listed here to keep things in one place
+#: ../roundup/roundupdb.py:150
+msgid "actor"
+msgstr "attore"
+
+#: ../roundup/roundupdb.py:150
+msgid "creation"
+msgstr "creazione"
+
+#: ../roundup/roundupdb.py:150
+msgid "creator"
+msgstr "creatore"
+
+#: ../roundup/roundupdb.py:308
+#, python-format
+msgid "New submission from %(authname)s%(authaddr)s:"
+msgstr ""
+"È stata aperta una nuova richiesta di assistenza da parte di\n"
+"%(authname)s%(authaddr)s.\n"
+"Il testo della nuova richiesta è il seguente:"
+
+#: ../roundup/roundupdb.py:311
+#, python-format
+msgid "%(authname)s%(authaddr)s added the comment:"
+msgstr ""
+"È stato aggiunto un nuovo messaggio da\n"
+"%(authname)s%(authaddr)s.\n"
+"Il testo del messaggio è il seguente:"
+
+#: ../roundup/roundupdb.py:314
+msgid "System message:"
+msgstr "Messaggio dal sistema:"
+
+#: ../roundup/roundupdb.py:597
+#, python-format
+msgid ""
+"\n"
+"Now:\n"
+"%(new)s\n"
+"Was:\n"
+"%(old)s"
+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 class] [[-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@server\n"
+" The username and password may be omitted:\n"
+"    pop username@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"
+"POPS:\n"
+" Connect to a POP server over ssl. This requires python 2.4 or later.\n"
+" This supports the same notation as POP.\n"
+"\n"
+"APOP:\n"
+" Same as POP, but using Authenticated POP:\n"
+"    apop username:password@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@server\n"
+" It also allows you to specify a specific mailbox other than INBOX using\n"
+" this format:\n"
+"    imap username:password@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@server [mailbox]\n"
+"\n"
+msgstr ""
+
+#: ../roundup/scripts/roundup_mailgw.py:151
+msgid "Error: not enough source specification information"
+msgstr "Errore: insufficienti informazioni sul sorgente"
+
+#: ../roundup/scripts/roundup_mailgw.py:167
+msgid "Error: a later version of python is required"
+msgstr "Erorre: è richiesta una versione più aggiornata di python"
+
+#: ../roundup/scripts/roundup_mailgw.py:170
+msgid "Error: pop specification not valid"
+msgstr "Errore: il pop server specificato non è valido."
+
+#: ../roundup/scripts/roundup_mailgw.py:177
+msgid "Error: apop specification not valid"
+msgstr "Errore: il apop server specificato non è valido."
+
+#: ../roundup/scripts/roundup_mailgw.py:189
+msgid ""
+"Error: The source must be either \"mailbox\", \"pop\", \"apop\", \"imap\" or "
+"\"imaps\""
+msgstr ""
+"Errore: la sorgente deve essere una tra \"mailbox\", \"pop\", \"apop\", "
+"\"imap\" o \"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>indice dei ticket Roundup</title></head>\n"
+"<body><h1>indice dei ticket Roundup</h1><ol>\n"
+
+#: ../roundup/scripts/roundup_server.py:293
+#, python-format
+msgid "Error: %s: %s"
+msgstr "Errore: %s: %s"
+
+#: ../roundup/scripts/roundup_server.py:303
+msgid "WARNING: ignoring \"-g\" argument, not root"
+msgstr "ATTENZIONE: ignoro il parametro \"-g\", non sei root"
+
+#: ../roundup/scripts/roundup_server.py:309
+msgid "Can't change groups - no grp module"
+msgstr "Non è possibile cambiare gruppo - nessun modulo grp"
+
+#: ../roundup/scripts/roundup_server.py:318
+#, python-format
+msgid "Group %(group)s doesn't exist"
+msgstr "Il gruppo %(group)s non esiste"
+
+#: ../roundup/scripts/roundup_server.py:329
+msgid "Can't run as root!"
+msgstr "Non può essere eseguito come root!"
+
+#: ../roundup/scripts/roundup_server.py:332
+msgid "WARNING: ignoring \"-u\" argument, not root"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:338
+msgid "Can't change users - no pwd module"
+msgstr "Non è possibile cambiare utente - nessun modulo pwd"
+
+#: ../roundup/scripts/roundup_server.py:347
+#, python-format
+msgid "User %(user)s doesn't exist"
+msgstr "L'utente $(user)s non esiste"
+
+#: ../roundup/scripts/roundup_server.py:481
+#, python-format
+msgid "Multiprocess mode \"%s\" is not available, switching to single-process"
+msgstr ""
+"La modalità multiprocesso non è disponibile, viene utilizzata quella a "
+"singolo processo"
+
+#: ../roundup/scripts/roundup_server.py:504
+#, python-format
+msgid "Unable to bind to port %s, port already in use."
+msgstr "Impossibile bindare alla porta %s, la porta risulta già in uso."
+
+#: ../roundup/scripts/roundup_server.py:572
+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:579
+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:586
+#, 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:741
+msgid "Instances must be name=home"
+msgstr "L'istanza deve essere nel formato nome=home"
+
+#: ../roundup/scripts/roundup_server.py:755
+#, python-format
+msgid "Configuration saved to %s"
+msgstr "Configurazione salvata in %s"
+
+#: ../roundup/scripts/roundup_server.py:773
+msgid "Sorry, you can't run the server as a daemon on this Operating System"
+msgstr ""
+"Spiacente, non è possibile utilizzare il server come demone su questo "
+"sistema operativo."
+
+#: ../roundup/scripts/roundup_server.py:788
+#, python-format
+msgid "Roundup server started on %(HOST)s:%(PORT)s"
+msgstr "Il server Roundup è stato attivato su %(HOST)s:%(PORT)s"
+
+#: ../templates/classic/html/_generic.collision.html:4
+#: ../templates/minimal/html/_generic.collision.html:4
+msgid "${class} Edit Collision - ${tracker}"
+msgstr "Collisione tra modifiche: ${class} - ${tracker}"
+
+#: ../templates/classic/html/_generic.collision.html:7
+#: ../templates/minimal/html/_generic.collision.html:7
+msgid "${class} Edit Collision"
+msgstr "Collisione tra modifiche: ${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"
+"  Si è verificata una collisione. Un altro utente ha modificato questo nodo\n"
+"  mentre tu lo stavi modificando. Per favore <a href='${context}'>ricarica</"
+"a>  il nodo e rivedi le tue modifiche.\n"
+
+#: ../templates/classic/html/_generic.help-empty.html:6
+msgid "Please specify your search parameters!"
+msgstr "Per favore specifica un parametro di ricerca."
+
+#: ../templates/classic/html/_generic.help-list.html:20
+#: ../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:35
+#: ../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:35
+#: ../templates/minimal/html/user.register.html:14
+msgid "You are not allowed to view this page."
+msgstr "Non si dispone dei permessi necessari per visualizzare questa pagina."
+
+#: ../templates/classic/html/_generic.help-list.html:34
+msgid "1..25 out of 50"
+msgstr "1..25 di 50"
+
+#: ../templates/classic/html/_generic.help-search.html:9
+msgid ""
+"Generic template ${template} or version for class ${classname} is not yet "
+"implemented"
+msgstr ""
+"Il template generico ${template} o la versione per la classe ${classname} "
+"non sono ancora stati implementati"
+
+#: ../templates/classic/html/_generic.help-submit.html:57
+#: ../templates/classic/html/_generic.help.html:31
+#: ../templates/minimal/html/_generic.help.html:31
+msgid " Cancel "
+msgstr " Annulla "
+
+#: ../templates/classic/html/_generic.help-submit.html:63
+#: ../templates/classic/html/_generic.help.html:34
+#: ../templates/minimal/html/_generic.help.html:34
+msgid " Apply "
+msgstr " Applica "
+
+#: ../templates/classic/html/_generic.help.html:9
+#: ../templates/classic/html/user.help.html:13
+#: ../templates/minimal/html/_generic.help.html:9
+msgid "${property} help - ${tracker}"
+msgstr "aiuto ${property} - ${tracker}"
+
+#: ../templates/classic/html/_generic.help.html:41
+#: ../templates/classic/html/help.html:21
+#: ../templates/classic/html/issue.index.html:80
+#: ../templates/minimal/html/_generic.help.html:41
+msgid "&lt;&lt; previous"
+msgstr "&laquo; precedente"
+
+#: ../templates/classic/html/_generic.help.html:53
+#: ../templates/classic/html/help.html:28
+#: ../templates/classic/html/issue.index.html:88
+#: ../templates/minimal/html/_generic.help.html:53
+msgid "${start}..${end} out of ${total}"
+msgstr "${start}..${end} di ${total}"
+
+#: ../templates/classic/html/_generic.help.html:57
+#: ../templates/classic/html/help.html:32
+#: ../templates/classic/html/issue.index.html:91
+#: ../templates/minimal/html/_generic.help.html:57
+msgid "next &gt;&gt;"
+msgstr "successivo &raquo;"
+
+#: ../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 "Modifica di ${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 "Modifica di ${class}"
+
+#: ../templates/classic/html/_generic.index.html:19
+#: ../templates/classic/html/_generic.item.html:16
+#: ../templates/classic/html/file.item.html:13
+#: ../templates/classic/html/issue.index.html:20
+#: ../templates/classic/html/issue.item.html:32
+#: ../templates/classic/html/msg.item.html:30
+#: ../templates/classic/html/user.index.html:13
+#: ../templates/classic/html/user.item.html:39
+#: ../templates/minimal/html/_generic.index.html:19
+#: ../templates/minimal/html/_generic.item.html:17
+#: ../templates/minimal/html/user.index.html:13
+#: ../templates/minimal/html/user.register.html:17
+msgid "Please login with your username and password."
+msgstr "Inserisci il tuo nome utente e la tua password."
+
+#: ../templates/classic/html/_generic.index.html:28
+#: ../templates/minimal/html/_generic.index.html:28
+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:50
+#: ../templates/minimal/html/_generic.index.html:50
+msgid "Edit Items"
+msgstr "Modifica L'Oggetto"
+
+#: ../templates/classic/html/file.index.html:4
+msgid "List of files - ${tracker}"
+msgstr "Elenco dei File - ${tracker}"
+
+#: ../templates/classic/html/file.index.html:5
+msgid "List of files"
+msgstr "Elenco dei File"
+
+#: ../templates/classic/html/file.index.html:10
+msgid "Download"
+msgstr ""
+
+#: ../templates/classic/html/file.index.html:11
+#: ../templates/classic/html/file.item.html:27
+msgid "Content Type"
+msgstr ""
+
+#: ../templates/classic/html/file.index.html:12
+msgid "Uploaded By"
+msgstr "Uploadato Da"
+
+#: ../templates/classic/html/file.index.html:13
+#: ../templates/classic/html/msg.item.html:48
+msgid "Date"
+msgstr "Data"
+
+#: ../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:23
+#: ../templates/classic/html/user.register.html:17
+msgid "Name"
+msgstr "Nome"
+
+#: ../templates/classic/html/file.item.html:45
+msgid "download"
+msgstr ""
+
+#: ../templates/classic/html/home.classlist.html:2
+#: ../templates/minimal/html/home.classlist.html:2
+msgid "List of classes - ${tracker}"
+msgstr "Elenco delle Classi - ${tracker}"
+
+#: ../templates/classic/html/home.classlist.html:4
+#: ../templates/minimal/html/home.classlist.html:4
+msgid "List of classes"
+msgstr "Elenco delle Classi"
+
+#: ../templates/classic/html/issue.index.html:4
+#: ../templates/classic/html/issue.index.html:10
+msgid "List of issues"
+msgstr "Elenco dei Ticket"
+
+#: ../templates/classic/html/issue.index.html:27
+#: ../templates/classic/html/issue.item.html:49
+msgid "Priority"
+msgstr "Priorità"
+
+#: ../templates/classic/html/issue.index.html:28
+msgid "ID"
+msgstr "ID"
+
+#: ../templates/classic/html/issue.index.html:29
+msgid "Creation"
+msgstr "Creazione"
+
+#: ../templates/classic/html/issue.index.html:30
+msgid "Activity"
+msgstr "Attività"
+
+#: ../templates/classic/html/issue.index.html:31
+msgid "Actor"
+msgstr "Attore"
+
+#: ../templates/classic/html/issue.index.html:32
+msgid "Topic"
+msgstr "Argomento"
+
+#: ../templates/classic/html/issue.index.html:33
+#: ../templates/classic/html/issue.item.html:44
+msgid "Title"
+msgstr "Titolo"
+
+#: ../templates/classic/html/issue.index.html:34
+#: ../templates/classic/html/issue.item.html:51
+msgid "Status"
+msgstr "Stato"
+
+#: ../templates/classic/html/issue.index.html:35
+msgid "Creator"
+msgstr "Creatore"
+
+#: ../templates/classic/html/issue.index.html:36
+msgid "Assigned&nbsp;To"
+msgstr "Assegnato&nbsp;A"
+
+#: ../templates/classic/html/issue.index.html:104
+msgid "Download as CSV"
+msgstr "Scarica come CSV"
+
+#: ../templates/classic/html/issue.index.html:114
+msgid "Sort on:"
+msgstr "Ordina per:"
+
+#: ../templates/classic/html/issue.index.html:118
+#: ../templates/classic/html/issue.index.html:139
+msgid "- nothing -"
+msgstr "- niente -"
+
+#: ../templates/classic/html/issue.index.html:126
+#: ../templates/classic/html/issue.index.html:147
+msgid "Descending:"
+msgstr "Decrescente:"
+
+#: ../templates/classic/html/issue.index.html:135
+msgid "Group on:"
+msgstr "Raggruppa per:"
+
+#: ../templates/classic/html/issue.index.html:154
+msgid "Redisplay"
+msgstr "Aggiorna"
+
+#: ../templates/classic/html/issue.item.html:7
+msgid "Issue ${id}: ${title} - ${tracker}"
+msgstr "Ticket ${id}: ${title} - ${tracker}"
+
+#: ../templates/classic/html/issue.item.html:10
+msgid "New Issue - ${tracker}"
+msgstr "Nuovo Ticket - ${tracker}"
+
+#: ../templates/classic/html/issue.item.html:14
+msgid "New Issue"
+msgstr "Nuovo Ticket"
+
+#: ../templates/classic/html/issue.item.html:16
+msgid "New Issue Editing"
+msgstr "Modifica Nuovo Ticket"
+
+#: ../templates/classic/html/issue.item.html:19
+msgid "Issue${id}"
+msgstr "Ticket${id}"
+
+#: ../templates/classic/html/issue.item.html:22
+msgid "Issue${id} Editing"
+msgstr "Modifica Ticket${id}"
+
+#: ../templates/classic/html/issue.item.html:56
+msgid "Superseder"
+msgstr "Soprassede"
+
+#: ../templates/classic/html/issue.item.html:61
+msgid "View:"
+msgstr "Visualizza:"
+
+#: ../templates/classic/html/issue.item.html:67
+msgid "Nosy List"
+msgstr "Lista Ficcanaso"
+
+#: ../templates/classic/html/issue.item.html:76
+msgid "Assigned To"
+msgstr "Assegnato a"
+
+#: ../templates/classic/html/issue.item.html:78
+msgid "Topics"
+msgstr "Argomenti"
+
+#: ../templates/classic/html/issue.item.html:86
+msgid "Change Note"
+msgstr "Modifica Nota"
+
+#: ../templates/classic/html/issue.item.html:94
+msgid "File"
+msgstr "File"
+
+#: ../templates/classic/html/issue.item.html:106
+msgid "Make a copy"
+msgstr "Crea una copia"
+
+#: ../templates/classic/html/issue.item.html:114
+#: ../templates/classic/html/user.item.html:152
+#: ../templates/classic/html/user.register.html:69
+#: ../templates/minimal/html/user.item.html:147
+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:&nbsp;i&nbsp;campi&nbsp;</td> <th class="
+"\"required\">evidenziati</th> <td>&nbsp;sono obbligatori.</td> </tr> </table>"
+
+#: ../templates/classic/html/issue.item.html:128
+msgid ""
+"Created on <b>${creation}</b> by <b>${creator}</b>, last changed <b>"
+"${activity}</b> by <b>${actor}</b>."
+msgstr ""
+"Creato il <b>${creation}</b> da <b>${creator}</b>, ultima modifica <b>"
+"${activity}</b> di <b>${actor}</b>."
+
+#: ../templates/classic/html/issue.item.html:132
+#: ../templates/classic/html/msg.item.html:61
+msgid "Files"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:134
+#: ../templates/classic/html/msg.item.html:63
+msgid "File name"
+msgstr "Nome del file"
+
+#: ../templates/classic/html/issue.item.html:135
+#: ../templates/classic/html/msg.item.html:64
+msgid "Uploaded"
+msgstr "Uploadato"
+
+#: ../templates/classic/html/issue.item.html:136
+msgid "Type"
+msgstr "Tipo"
+
+#: ../templates/classic/html/issue.item.html:137
+#: ../templates/classic/html/query.edit.html:30
+msgid "Edit"
+msgstr "Modifica"
+
+#: ../templates/classic/html/issue.item.html:138
+msgid "Remove"
+msgstr "Rimuovi"
+
+#: ../templates/classic/html/issue.item.html:158
+#: ../templates/classic/html/issue.item.html:179
+#: ../templates/classic/html/query.edit.html:50
+msgid "remove"
+msgstr "rimuovi"
+
+#: ../templates/classic/html/issue.item.html:165
+#: ../templates/classic/html/msg.index.html:9
+msgid "Messages"
+msgstr "Messaggi"
+
+#: ../templates/classic/html/issue.item.html:169
+msgid "msg${id} (view)"
+msgstr "msg${id} (visualizza)"
+
+#: ../templates/classic/html/issue.item.html:170
+msgid "Author: ${author}"
+msgstr "Autore: ${author}"
+
+#: ../templates/classic/html/issue.item.html:172
+msgid "Date: ${date}"
+msgstr "Data: ${date}"
+
+#: ../templates/classic/html/issue.search.html:2
+msgid "Issue searching - ${tracker}"
+msgstr "Cerca ticket - ${tracker}"
+
+#: ../templates/classic/html/issue.search.html:4
+msgid "Issue searching"
+msgstr "Cerca Ticket"
+
+#: ../templates/classic/html/issue.search.html:31
+msgid "Filter on"
+msgstr "Filtra su"
+
+#: ../templates/classic/html/issue.search.html:32
+msgid "Display"
+msgstr "Visualizza"
+
+#: ../templates/classic/html/issue.search.html:33
+msgid "Sort on"
+msgstr "Ordina per"
+
+#: ../templates/classic/html/issue.search.html:34
+msgid "Group on"
+msgstr "Raggruppa per"
+
+#: ../templates/classic/html/issue.search.html:38
+msgid "All text*:"
+msgstr "Tutti i testi*:"
+
+#: ../templates/classic/html/issue.search.html:46
+msgid "Title:"
+msgstr "Titolo:"
+
+#: ../templates/classic/html/issue.search.html:56
+msgid "Topic:"
+msgstr "Argomento:"
+
+#: ../templates/classic/html/issue.search.html:64
+msgid "ID:"
+msgstr "ID:"
+
+#: ../templates/classic/html/issue.search.html:72
+msgid "Creation Date:"
+msgstr "Data creazione:"
+
+#: ../templates/classic/html/issue.search.html:83
+msgid "Creator:"
+msgstr "Creatore:"
+
+#: ../templates/classic/html/issue.search.html:85
+msgid "created by me"
+msgstr "creato da me"
+
+#: ../templates/classic/html/issue.search.html:94
+msgid "Activity:"
+msgstr "Attività"
+
+#: ../templates/classic/html/issue.search.html:105
+msgid "Actor:"
+msgstr "Attore:"
+
+#: ../templates/classic/html/issue.search.html:107
+msgid "done by me"
+msgstr "fatto da me"
+
+#: ../templates/classic/html/issue.search.html:118
+msgid "Priority:"
+msgstr "Priorità:"
+
+#: ../templates/classic/html/issue.search.html:120
+#: ../templates/classic/html/issue.search.html:136
+msgid "not selected"
+msgstr "non selezionato"
+
+#: ../templates/classic/html/issue.search.html:131
+msgid "Status:"
+msgstr "Stato:"
+
+#: ../templates/classic/html/issue.search.html:134
+msgid "not resolved"
+msgstr "non risolto"
+
+#: ../templates/classic/html/issue.search.html:149
+msgid "Assigned to:"
+msgstr "Assegnato a:"
+
+#: ../templates/classic/html/issue.search.html:152
+msgid "assigned to me"
+msgstr "assegnato a me"
+
+#: ../templates/classic/html/issue.search.html:154
+msgid "unassigned"
+msgstr "non assegnato"
+
+#: ../templates/classic/html/issue.search.html:164
+msgid "No Sort or group:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:172
+msgid "Pagesize:"
+msgstr "Risposte per pagina"
+
+#: ../templates/classic/html/issue.search.html:178
+msgid "Start With:"
+msgstr "Inizia con:"
+
+#: ../templates/classic/html/issue.search.html:184
+msgid "Sort Descending:"
+msgstr "Ordinamento Decrescente:"
+
+#: ../templates/classic/html/issue.search.html:191
+msgid "Group Descending:"
+msgstr "Raggruppamento Decrescente:"
+
+#: ../templates/classic/html/issue.search.html:198
+msgid "Query name**:"
+msgstr "Nome della query**:"
+
+#: ../templates/classic/html/issue.search.html:210
+#: ../templates/classic/html/page.html:43
+#: ../templates/classic/html/page.html:92
+#: ../templates/classic/html/user.help-search.html:69
+#: ../templates/minimal/html/page.html:43
+#: ../templates/minimal/html/page.html:91
+msgid "Search"
+msgstr "Ricerca"
+
+#: ../templates/classic/html/issue.search.html:215
+msgid "*: The \"all text\" field will look in message bodies and issue titles"
+msgstr ""
+"*: Il campo \"tutti i testi\" cerca nel corpo del messaggio e nel titolo del "
+"ticket"
+
+#: ../templates/classic/html/issue.search.html:218
+msgid ""
+"**: If you supply a name, the query will be saved off and available as a "
+"link in the sidebar"
+msgstr ""
+"**: Se viene fornito un nome, la query verrà salvata e resa disponibile come "
+"link"
+
+#: ../templates/classic/html/keyword.item.html:3
+msgid "Keyword editing - ${tracker}"
+msgstr "Modifica Parole Chiave - ${tracker}"
+
+#: ../templates/classic/html/keyword.item.html:5
+msgid "Keyword editing"
+msgstr "Modifica Parole Chiave"
+
+#: ../templates/classic/html/keyword.item.html:11
+msgid "Existing Keywords"
+msgstr "Parole Chiave Inserite"
+
+#: ../templates/classic/html/keyword.item.html:20
+msgid ""
+"To edit an existing keyword (for spelling or typing errors), click on its "
+"entry above."
+msgstr ""
+"Per modificare una parola chiave esistente (per errori di pronuncia o "
+"digitazione), clicca sul link sopra."
+
+#: ../templates/classic/html/keyword.item.html:27
+msgid "To create a new keyword, enter it below and click \"Submit New Entry\"."
+msgstr ""
+"Per creane una nuova parola chiave, inseriscila sotto e clicca su "
+"\"Inserisci nuovo\""
+
+#: ../templates/classic/html/keyword.item.html:37
+msgid "Keyword"
+msgstr "Parola chiave"
+
+#: ../templates/classic/html/msg.index.html:3
+msgid "List of messages - ${tracker}"
+msgstr "Elenco Messaggi - ${tracker}"
+
+#: ../templates/classic/html/msg.index.html:5
+msgid "Message listing"
+msgstr "Elenco Messaggi"
+
+#: ../templates/classic/html/msg.item.html:6
+msgid "Message ${id} - ${tracker}"
+msgstr "Messaggio ${id} - ${tracker}"
+
+#: ../templates/classic/html/msg.item.html:9
+msgid "New Message - ${tracker}"
+msgstr "Nuovo Messaggio - ${tracker}"
+
+#: ../templates/classic/html/msg.item.html:13
+msgid "New Message"
+msgstr "Nuovo Messagggio"
+
+#: ../templates/classic/html/msg.item.html:15
+msgid "New Message Editing"
+msgstr "Modifica Nuovo Messaggio"
+
+#: ../templates/classic/html/msg.item.html:18
+msgid "Message${id}"
+msgstr "Messaggio${id}"
+
+#: ../templates/classic/html/msg.item.html:21
+msgid "Message${id} Editing"
+msgstr "Modifica Messaggio${id}"
+
+#: ../templates/classic/html/msg.item.html:38
+msgid "Author"
+msgstr "Autore"
+
+#: ../templates/classic/html/msg.item.html:43
+msgid "Recipients"
+msgstr "Destinatario"
+
+#: ../templates/classic/html/msg.item.html:54
+msgid "Content"
+msgstr "Contenuto"
+
+#: ../templates/classic/html/page.html:54
+#: ../templates/minimal/html/page.html:53
+msgid "<b>Your Queries</b> (<a href=\"query?@template=edit\">edit</a>)"
+msgstr "<b>Le tue Query</b> (<a href=\"query?@template=edit\">modifica</a>)"
+
+#: ../templates/classic/html/page.html:65
+#: ../templates/minimal/html/page.html:64
+msgid "Issues"
+msgstr "Ticket"
+
+#: ../templates/classic/html/page.html:67
+#: ../templates/classic/html/page.html:105
+#: ../templates/minimal/html/page.html:66
+#: ../templates/minimal/html/page.html:104
+msgid "Create New"
+msgstr "Crea Nuovo"
+
+#: ../templates/classic/html/page.html:69
+#: ../templates/minimal/html/page.html:68
+msgid "Show Unassigned"
+msgstr "Mostra Non Assegnati"
+
+#: ../templates/classic/html/page.html:81
+#: ../templates/minimal/html/page.html:80
+msgid "Show All"
+msgstr "Mostra Tutti"
+
+#: ../templates/classic/html/page.html:93
+#: ../templates/minimal/html/page.html:92
+msgid "Show issue:"
+msgstr "Mostra ticket:"
+
+#: ../templates/classic/html/page.html:103
+#: ../templates/minimal/html/page.html:102
+msgid "Keywords"
+msgstr "Parole chiave"
+
+#: ../templates/classic/html/page.html:108
+#: ../templates/minimal/html/page.html:107
+msgid "Edit Existing"
+msgstr "Modifica"
+
+#: ../templates/classic/html/page.html:114
+#: ../templates/minimal/html/page.html:113
+msgid "Administration"
+msgstr "Amministrazione"
+
+#: ../templates/classic/html/page.html:116
+#: ../templates/minimal/html/page.html:115
+msgid "Class List"
+msgstr "Elenco Classi"
+
+#: ../templates/classic/html/page.html:120
+#: ../templates/minimal/html/page.html:119
+msgid "User List"
+msgstr "Elenco Utenti"
+
+#: ../templates/classic/html/page.html:122
+#: ../templates/minimal/html/page.html:121
+msgid "Add User"
+msgstr "Aggiungi Utente"
+
+#: ../templates/classic/html/page.html:129
+#: ../templates/classic/html/page.html:135
+#: ../templates/minimal/html/page.html:128
+#: ../templates/minimal/html/page.html:134
+msgid "Login"
+msgstr "Login"
+
+#: ../templates/classic/html/page.html:134
+#: ../templates/minimal/html/page.html:133
+msgid "Remember me?"
+msgstr "Ricorda la mia login"
+
+#: ../templates/classic/html/page.html:138
+#: ../templates/classic/html/user.register.html:63
+#: ../templates/minimal/html/page.html:137
+#: ../templates/minimal/html/user.register.html:61
+msgid "Register"
+msgstr "Registra"
+
+#: ../templates/classic/html/page.html:141
+#: ../templates/minimal/html/page.html:140
+msgid "Lost&nbsp;your&nbsp;login?"
+msgstr "Dimenticato&nbsp;la&nbsp;tua&nbsp;Login?"
+
+#: ../templates/classic/html/page.html:146
+#: ../templates/minimal/html/page.html:145
+msgid "Hello, ${user}"
+msgstr "Ciao, ${user}"
+
+#: ../templates/classic/html/page.html:148
+msgid "Your Issues"
+msgstr "I Tuoi Ticket"
+
+#: ../templates/classic/html/page.html:160
+#: ../templates/minimal/html/page.html:147
+msgid "Your Details"
+msgstr "I Tuoi Dettagli"
+
+#: ../templates/classic/html/page.html:162
+#: ../templates/minimal/html/page.html:149
+msgid "Logout"
+msgstr ""
+
+#: ../templates/classic/html/page.html:166
+#: ../templates/minimal/html/page.html:153
+msgid "Help"
+msgstr "Aiuto"
+
+#: ../templates/classic/html/page.html:167
+#: ../templates/minimal/html/page.html:154
+msgid "Roundup docs"
+msgstr "Documentazione su Roundup"
+
+#: ../templates/classic/html/page.html:177
+#: ../templates/minimal/html/page.html:164
+msgid "clear this message"
+msgstr "non visualizzare questo messaggio"
+
+#: ../templates/classic/html/page.html:241
+#: ../templates/classic/html/page.html:256
+#: ../templates/classic/html/page.html:270
+#: ../templates/minimal/html/page.html:228
+#: ../templates/minimal/html/page.html:243
+#: ../templates/minimal/html/page.html:257
+msgid "don't care"
+msgstr "non importa"
+
+#: ../templates/classic/html/page.html:243
+#: ../templates/classic/html/page.html:258
+#: ../templates/classic/html/page.html:271
+#: ../templates/minimal/html/page.html:230
+#: ../templates/minimal/html/page.html:245
+#: ../templates/minimal/html/page.html:258
+msgid "------------"
+msgstr "------------"
+
+#: ../templates/classic/html/page.html:299
+#: ../templates/minimal/html/page.html:286
+msgid "no value"
+msgstr "nessun valore"
+
+#: ../templates/classic/html/query.edit.html:4
+msgid "\"Your Queries\" Editing - ${tracker}"
+msgstr "Modifica de \"Le tue Query\" - ${tracker}"
+
+#: ../templates/classic/html/query.edit.html:6
+msgid "\"Your Queries\" Editing"
+msgstr "Modifica de \"Le tue Query\""
+
+#: ../templates/classic/html/query.edit.html:11
+msgid "You are not allowed to edit queries."
+msgstr "Non si dispone dei permessi necessari per modificare delle query."
+
+#: ../templates/classic/html/query.edit.html:28
+msgid "Query"
+msgstr "Query"
+
+#: ../templates/classic/html/query.edit.html:29
+msgid "Include in \"Your Queries\""
+msgstr "Includi in \"Le tue Query\""
+
+#: ../templates/classic/html/query.edit.html:31
+msgid "Private to you?"
+msgstr "Personale?"
+
+#: ../templates/classic/html/query.edit.html:44
+msgid "leave out"
+msgstr "lascia fuori"
+
+#: ../templates/classic/html/query.edit.html:45
+msgid "include"
+msgstr "includi"
+
+#: ../templates/classic/html/query.edit.html:49
+msgid "leave in"
+msgstr "lascia dentro"
+
+#: ../templates/classic/html/query.edit.html:54
+msgid "[query is retired]"
+msgstr "[la query è stata ritirata]"
+
+#: ../templates/classic/html/query.edit.html:67
+#: ../templates/classic/html/query.edit.html:92
+msgid "edit"
+msgstr "modifica"
+
+#: ../templates/classic/html/query.edit.html:71
+msgid "yes"
+msgstr "sì"
+
+#: ../templates/classic/html/query.edit.html:73
+msgid "no"
+msgstr "no"
+
+#: ../templates/classic/html/query.edit.html:79
+msgid "Delete"
+msgstr "Elimina"
+
+#: ../templates/classic/html/query.edit.html:94
+msgid "[not yours to edit]"
+msgstr "[non sei il proprietario della query"
+
+#: ../templates/classic/html/query.edit.html:102
+msgid "Save Selection"
+msgstr "Salva la selezione"
+
+#: ../templates/classic/html/user.forgotten.html:3
+msgid "Password reset request - ${tracker}"
+msgstr "Richiesta nuova password - %{tracker}"
+
+#: ../templates/classic/html/user.forgotten.html:5
+msgid "Password reset request"
+msgstr "Richiesta nuova password"
+
+#: ../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 "Indirizzo di Email:"
+
+#: ../templates/classic/html/user.forgotten.html:24
+#: ../templates/classic/html/user.forgotten.html:34
+msgid "Request password reset"
+msgstr "Richiedi una nuova password"
+
+#: ../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 "Nome Utente:"
+
+#: ../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.help-search.html:73
+msgid "Pagesize"
+msgstr "Dimensione della Pagina"
+
+#: ../templates/classic/html/user.help.html:43
+msgid ""
+"Your browser is not capable of using frames; you should be redirected "
+"immediately, or visit ${link}."
+msgstr ""
+"Il tuo browser non permette di utilizzare i frame; verrai rediretto "
+"immediatamente, oppure alternativamente visita ${link}."
+
+#: ../templates/classic/html/user.index.html:3
+#: ../templates/minimal/html/user.index.html:3
+msgid "User listing - ${tracker}"
+msgstr "Elenco Utenti - ${tracker}"
+
+#: ../templates/classic/html/user.index.html:5
+#: ../templates/minimal/html/user.index.html:5
+msgid "User listing"
+msgstr "Elenco Utenti"
+
+#: ../templates/classic/html/user.index.html:19
+#: ../templates/minimal/html/user.index.html:19
+msgid "Username"
+msgstr "Nome Utente"
+
+#: ../templates/classic/html/user.index.html:20
+msgid "Real name"
+msgstr "Nome Completo"
+
+#: ../templates/classic/html/user.index.html:21
+#: ../templates/classic/html/user.register.html:45
+msgid "Organisation"
+msgstr "Organizzazione"
+
+#: ../templates/classic/html/user.index.html:22
+#: ../templates/minimal/html/user.index.html:20
+msgid "Email address"
+msgstr "Indirizzo di Email"
+
+#: ../templates/classic/html/user.index.html:23
+msgid "Phone number"
+msgstr "Telefono"
+
+#: ../templates/classic/html/user.index.html:24
+msgid "Retire"
+msgstr "Dismetti"
+
+#: ../templates/classic/html/user.index.html:37
+msgid "retire"
+msgstr "dismetti"
+
+#: ../templates/classic/html/user.item.html:9
+#: ../templates/minimal/html/user.item.html:9
+msgid "User ${id}: ${title} - ${tracker}"
+msgstr "Utente ${id}: ${title} - ${tracker}"
+
+#: ../templates/classic/html/user.item.html:12
+#: ../templates/minimal/html/user.item.html:12
+msgid "New User - ${tracker}"
+msgstr "Nuovo Utente - ${tracker}"
+
+#: ../templates/classic/html/user.item.html:21
+#: ../templates/minimal/html/user.item.html:21
+msgid "New User"
+msgstr "Nuovo Utente"
+
+#: ../templates/classic/html/user.item.html:23
+#: ../templates/minimal/html/user.item.html:23
+msgid "New User Editing"
+msgstr "Modifica Nuovo Utente"
+
+#: ../templates/classic/html/user.item.html:26
+#: ../templates/minimal/html/user.item.html:26
+msgid "User${id}"
+msgstr "Utente${id}"
+
+#: ../templates/classic/html/user.item.html:29
+#: ../templates/minimal/html/user.item.html:29
+msgid "User${id} Editing"
+msgstr "Modifica Utente${id}"
+
+#: ../templates/classic/html/user.item.html:79
+#: ../templates/classic/html/user.register.html:33
+#: ../templates/minimal/html/user.item.html:74
+#: ../templates/minimal/html/user.register.html:41
+msgid "Roles"
+msgstr "Ruoli"
+
+#: ../templates/classic/html/user.item.html:87
+#: ../templates/minimal/html/user.item.html:82
+msgid "(to give the user more than one role, enter a comma,separated,list)"
+msgstr ""
+"(per fornire ad un utente più di un ruolo, inserire l'elenco separato da "
+"spazi)"
+
+#: ../templates/classic/html/user.item.html:108
+#: ../templates/minimal/html/user.item.html:103
+msgid "(this is a numeric hour offset, the default is ${zone})"
+msgstr ""
+"(questo è la differenza in numero di ore, il valore predefinito è ${zone})"
+
+#: ../templates/classic/html/user.item.html:129
+#: ../templates/classic/html/user.register.html:53
+#: ../templates/minimal/html/user.item.html:124
+#: ../templates/minimal/html/user.register.html:53
+msgid "Alternate E-mail addresses<br>One address per line"
+msgstr "Indirizzi di mail alternativi<br>Un indirizzo per linea"
+
+#: ../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 "Registrando con ${tracker}"
+
+#: ../templates/classic/html/user.register.html:21
+#: ../templates/minimal/html/user.register.html:29
+msgid "Login Name"
+msgstr "Nome Utente"
+
+#: ../templates/classic/html/user.register.html:25
+#: ../templates/minimal/html/user.register.html:33
+msgid "Login Password"
+msgstr "Password"
+
+#: ../templates/classic/html/user.register.html:29
+#: ../templates/minimal/html/user.register.html:37
+msgid "Confirm Password"
+msgstr "Conferma Password"
+
+#: ../templates/classic/html/user.register.html:41
+msgid "Phone"
+msgstr "Telefono"
+
+#: ../templates/classic/html/user.register.html:49
+#: ../templates/minimal/html/user.register.html:49
+msgid "E-mail address"
+msgstr "Indirizzo di Email"
+
+#: ../templates/classic/html/user.rego_progress.html:4
+#: ../templates/minimal/html/user.rego_progress.html:4
+msgid "Registration in progress - ${tracker}"
+msgstr "Registrazione in atto - ${tracker}"
+
+#: ../templates/classic/html/user.rego_progress.html:6
+#: ../templates/minimal/html/user.rego_progress.html:6
+msgid "Registration in progress..."
+msgstr "Registrazione in atto..."
+
+#: ../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 ""
+"Riceverai a breve una mail di conferma della tua registrazione. Per "
+"completare il processo di registrazione, visita il link indicato nella mail."
+
+#: ../templates/classic/initial_data.py:5
+msgid "critical"
+msgstr "critico"
+
+#: ../templates/classic/initial_data.py:6
+msgid "urgent"
+msgstr "urgente"
+
+#: ../templates/classic/initial_data.py:7
+msgid "bug"
+msgstr "bug"
+
+#: ../templates/classic/initial_data.py:8
+msgid "feature"
+msgstr ""
+
+#: ../templates/classic/initial_data.py:9
+msgid "wish"
+msgstr ""
+
+#: ../templates/classic/initial_data.py:12
+msgid "unread"
+msgstr ""
+
+#: ../templates/classic/initial_data.py:13
+msgid "deferred"
+msgstr ""
+
+#: ../templates/classic/initial_data.py:14
+msgid "chatting"
+msgstr ""
+
+#: ../templates/classic/initial_data.py:15
+msgid "need-eg"
+msgstr ""
+
+#: ../templates/classic/initial_data.py:16
+msgid "in-progress"
+msgstr ""
+
+#: ../templates/classic/initial_data.py:17
+msgid "testing"
+msgstr ""
+
+#: ../templates/classic/initial_data.py:18
+msgid "done-cbb"
+msgstr ""
+
+#: ../templates/classic/initial_data.py:19
+msgid "resolved"
+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 "Per favore seleziona una delle opzioni dal menù a sinistra"
+
+#: ../templates/minimal/html/home.html:19
+msgid "Please log in or register."
+msgstr "Per favore esegui la login o la registrazione."
diff --git a/locale/lt.po b/locale/lt.po
new file mode 100755 (executable)
index 0000000..8b68a83
--- /dev/null
@@ -0,0 +1,3480 @@
+# Lithuanian message file for Roundup Issue Tracker
+# Aiste Kesminaite <aiste@pov.lt>, 2005
+#
+# $Id: lt.po,v 1.8 2007-01-04 17:14:29 a1s Exp $
+#
+# roundup.pot revision 1.20
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: roundup-1.1.2\n"
+"Report-Msgid-Bugs-To: roundup-devel@lists.sourceforge.net\n"
+"POT-Creation-Date: 2006-11-16 16:24+0200\n"
+"PO-Revision-Date: 2006-11-22 20:17+0300\n"
+"Last-Translator: Nerijus Baliunas <nerijus@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:981
+#: ../roundup/admin.py:1030
+#: ../roundup/admin.py:1052
+#: ../roundup/admin.py:85:981
+#: :1030:1052
+#, 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
+#: ../roundup/admin.py:95: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"
+" -V                -- be verbose when importing\n"
+" -v                -- report Roundup and Python versions (and quit)\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 <eilutė>       -- išvedant duomenų sąrašus, atskirti eilute.\n"
+" -s                -- išvedant duomenų sąrašus, atskirti juos tarpais.\n"
+"                      Taip pat kaip '-S \" \"'.\n"
+" -V                -- importuojant rodyti daugiau informacijos\n"
+" -v                -- parodyti Roundup ir Python versijas (ir baigti darbą)\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:140
+msgid "Commands:"
+msgstr "Komandos:"
+
+#: ../roundup/admin.py:147
+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:177
+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:240
+#, python-format
+msgid "%s:"
+msgstr "%s:"
+
+#: ../roundup/admin.py:245
+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:268
+#, 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:340
+#: ../roundup/admin.py:396
+#: ../roundup/admin.py:340:396
+msgid "Templates:"
+msgstr "Šablonai:"
+
+# ../roundup/admin.py:341 :398
+#: ../roundup/admin.py:343
+#: ../roundup/admin.py:407
+#: ../roundup/admin.py:343:407
+msgid "Back ends:"
+msgstr "Duomenų saugyklos:"
+
+#: ../roundup/admin.py:346
+msgid ""
+"Usage: install [template [backend [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 and backend may be specified on the command-line\n"
+"        as arguments, in that order.\n"
+"\n"
+"        Command line arguments following the backend allows you to\n"
+"        pass initial values 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 [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:369
+#: ../roundup/admin.py:466
+#: ../roundup/admin.py:527
+#: ../roundup/admin.py:606
+#: ../roundup/admin.py:656
+#: ../roundup/admin.py:714
+#: ../roundup/admin.py:735
+#: ../roundup/admin.py:763
+#: ../roundup/admin.py:834
+#: ../roundup/admin.py:901
+#: ../roundup/admin.py:972
+#: ../roundup/admin.py:1020
+#: ../roundup/admin.py:1042
+#: ../roundup/admin.py:1072
+#: ../roundup/admin.py:1171
+#: ../roundup/admin.py:1243
+#: ../roundup/admin.py:369:466
+#: :1020:1042
+#: :1072:1171
+#: :1243
+#: :527:606
+#: :656:714
+#: :735:763
+#: :834:901:972
+msgid "Not enough arguments supplied"
+msgstr "Paduota nepakankamai argumentų"
+
+#: ../roundup/admin.py:375
+#, python-format
+msgid "Instance home parent directory \"%(parent)s\" does not exist"
+msgstr "Namų direktorijos tėvinė direktorija \"%(parent)s\" neegzistuoja"
+
+#: ../roundup/admin.py:383
+#, 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:398
+msgid "Select template [classic]: "
+msgstr "Pasirinkite šabloną [klasikinis]: "
+
+#: ../roundup/admin.py:409
+msgid "Select backend [anydbm]: "
+msgstr "Pasirinkite duomenų saugyklą [anydbm]: "
+
+#: ../roundup/admin.py:419
+#, python-format
+msgid "Error in configuration settings: \"%s\""
+msgstr "Klaida konfigūracijos nustatymuose: \"%s\""
+
+#: ../roundup/admin.py:428
+#, 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:438
+msgid " ... at a minimum, you must set following options:"
+msgstr " ... mažiausiai turėtumėte nustalyti šias parinktis:"
+
+#: ../roundup/admin.py:443
+#, 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:461
+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:471
+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:485
+msgid "Admin Password: "
+msgstr "Administratoriaus slaptažodis: "
+
+#: ../roundup/admin.py:486
+msgid "       Confirm: "
+msgstr "       Patvirtinkite: "
+
+#: ../roundup/admin.py:490
+msgid "Instance home does not exist"
+msgstr "Namų direktorija neegzistuoja"
+
+#: ../roundup/admin.py:494
+msgid "Instance has not been installed"
+msgstr "Egzempliorius nebuvo įdiegtas"
+
+#: ../roundup/admin.py:499
+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:520
+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:560
+#: ../roundup/admin.py:575
+#: ../roundup/admin.py:560:575
+#, 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:583
+#: ../roundup/admin.py:983
+#: ../roundup/admin.py:1032
+#: ../roundup/admin.py:1054
+#: ../roundup/admin.py:583:983
+#: :1032:1054
+#, python-format
+msgid "no such %(classname)s node \"%(nodeid)s\""
+msgstr "nėra tokio %(classname)s elemento \"%(nodeid)s\""
+
+#: ../roundup/admin.py:585
+#, python-format
+msgid "no such %(classname)s property \"%(propname)s\""
+msgstr "nėra tokio %(classname)s parinkties \"%(propname)s\""
+
+#: ../roundup/admin.py:594
+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:648
+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:701
+#: ../roundup/admin.py:854
+#: ../roundup/admin.py:866
+#: ../roundup/admin.py:920
+#: ../roundup/admin.py:701:854
+#: :866:920
+#, python-format
+msgid "%(classname)s has no property \"%(propname)s\""
+msgstr "%(classname)s neturi parinkties \"%(propname)s\""
+
+#: ../roundup/admin.py:708
+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:723
+#, python-format
+msgid "%(key)s: %(value)s (key property)"
+msgstr "%(key)s: %(value)s (key property)"
+
+#: ../roundup/admin.py:725
+#, python-format
+msgid "%(key)s: %(value)s"
+msgstr "%(key)s: %(value)s"
+
+#: ../roundup/admin.py:728
+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:752
+#, python-format
+msgid "%(key)s: %(value)r"
+msgstr "%(key)s: %(value)r"
+
+#: ../roundup/admin.py:755
+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:782
+#, python-format
+msgid "%(propname)s (Password): "
+msgstr "%(propname)s (Slaptažodis): "
+
+#: ../roundup/admin.py:784
+#, python-format
+msgid "   %(propname)s (Again): "
+msgstr "   %(propname)s (Pakartoti): "
+
+#: ../roundup/admin.py:786
+msgid "Sorry, try again..."
+msgstr "Bandykite dar kartą..."
+
+#: ../roundup/admin.py:790
+#, python-format
+msgid "%(propname)s (%(proptype)s): "
+msgstr "%(propname)s (%(proptype)s): "
+
+#: ../roundup/admin.py:808
+#, python-format
+msgid "you must provide the \"%(propname)s\" property."
+msgstr "turite pateikti parinktį \"%(propname)s\"."
+
+#: ../roundup/admin.py:819
+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:832
+msgid "Too many arguments supplied"
+msgstr "Pateikta per daug argumentų"
+
+#: ../roundup/admin.py:868
+#, python-format
+msgid "%(nodeid)4s: %(value)s"
+msgstr "%(nodeid)4s: %(value)s"
+
+#: ../roundup/admin.py:872
+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:916
+#, python-format
+msgid "\"%(spec)s\" not name:width"
+msgstr "\"%(spec)s\" ne vardas:plotis"
+
+#: ../roundup/admin.py:966
+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:987
+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:1001
+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:1013
+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:1036
+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:1058
+msgid ""
+"Usage: export [[-]class[,class]] export_dir\n"
+"        Export the database to colon-separated-value files.\n"
+"        To exclude the files (e.g. for the msg or file class),\n"
+"        use the exporttables command.\n"
+"\n"
+"        Optionally limit the export to just the named classes\n"
+"        or exclude the named classes, if the 1st argument starts with '-'.\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ų failą.\n"
+"        Norėdami neįtraukti failų (pvz. msg ar file klasių), naudokite\n"
+"        exporttables komandą.\n"
+"\n"
+"        Galima apriboti eksportą tik tam tikromis klasėmis arba neįtraukti\n"
+"        tam tikrų klasių, jei pirmas argumentas prasideda '-'.\n"
+"\n"
+"         Ši komanda eksportuoja dabartinius duomenis iš duomenų bazės\n"
+"        į kableliais atskirtų reikšmių failus, kurie išsaugomi nurodytoje\n"
+"        direktorijoje.\n"
+"        "
+
+#: ../roundup/admin.py:1136
+msgid ""
+"Usage: exporttables [[-]class[,class]] export_dir\n"
+"        Export the database to colon-separated-value files, excluding the\n"
+"        files below $TRACKER_HOME/db/files/ (which can be archived separately).\n"
+"        To include the files, use the export command.\n"
+"\n"
+"        Optionally limit the export to just the named classes\n"
+"        or exclude the named classes, if the 1st argument starts with '-'.\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: exporttables [[-]klasė[,klasė]] eksporto_direktorija\n"
+"        Eksportuoti duomenų bazę kaip kableliais atskirtų reikšmių failą,\n"
+"        neįtraukiant $TRACKER_HOME/db/files/ failų (jie gali būti archyvuojami\n"
+"        atskirai). Norėdami įtraukti failus, naudokite export komandą.\n"
+"\n"
+"        Galima apriboti eksportą tik tam tikromis klasėmis arba neįtraukti\n"
+"        tam tikrų klasių, jei pirmas argumentas prasideda '-'.\n"
+"\n"
+"        Ši komanda eksportuoja dabartinius duomenis iš duomenų bazės\n"
+"        į kableliais atskirtų reikšmių failus, kurie išsaugomi nurodytoje\n"
+"        direktorijoje.\n"
+"        "
+
+#: ../roundup/admin.py:1151
+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:1225
+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:1253
+msgid "Invalid format"
+msgstr "Netinkamas formatas"
+
+#: ../roundup/admin.py:1263
+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:1277
+#, python-format
+msgid "no such item \"%(designator)s\""
+msgstr "nėra elemento \"%(designator)s\""
+
+#: ../roundup/admin.py:1287
+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:1295
+#, python-format
+msgid "No such Role \"%(role)s\""
+msgstr "Nėra tokios rolės \"%(role)s\""
+
+#: ../roundup/admin.py:1301
+#, python-format
+msgid "New Web users get the Roles \"%(role)s\""
+msgstr "Naujiems web vartotojams suteikiamos rolės \"%(role)s\""
+
+#: ../roundup/admin.py:1303
+#, python-format
+msgid "New Web users get the Role \"%(role)s\""
+msgstr "Naujiems web vartotojams suteikiama rolė \"%(role)s\""
+
+#: ../roundup/admin.py:1306
+#, 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:1308
+#, 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:1311
+#, python-format
+msgid "Role \"%(name)s\":"
+msgstr "Rolė \"%(name)s\":"
+
+#: ../roundup/admin.py:1316
+#, 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:1319
+#, python-format
+msgid " %(description)s (%(name)s for \"%(klass)s\" only)"
+msgstr " %(description)s (%(name)s skirta tik \"%(klass)s\")"
+
+#: ../roundup/admin.py:1322
+#, python-format
+msgid " %(description)s (%(name)s)"
+msgstr " %(description)s (%(name)s)"
+
+#: ../roundup/admin.py:1351
+#, 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:1357
+#, python-format
+msgid "Multiple commands match \"%(command)s\": %(list)s"
+msgstr "Kelios komandos atitinka \"%(command)s\": %(list)s"
+
+#: ../roundup/admin.py:1364
+msgid "Enter tracker home: "
+msgstr "Įveskite tracker'io namų direktoriją: "
+
+# ../roundup/admin.py:1312 :1318 :1338
+#: ../roundup/admin.py:1371
+#: ../roundup/admin.py:1377
+#: ../roundup/admin.py:1397
+#: ../roundup/admin.py:1371:1377:1397
+#, python-format
+msgid "Error: %(message)s"
+msgstr "Klaida: %(message)s"
+
+#: ../roundup/admin.py:1385
+#, python-format
+msgid "Error: Couldn't open tracker: %(message)s"
+msgstr "Klaida: Negaliu atidaryti tracker'io: %(message)s"
+
+#: ../roundup/admin.py:1410
+#, 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:1415
+msgid "Note: command history and editing not available"
+msgstr "Pastaba: komandų archyvas ir redagavimas neprieinami"
+
+#: ../roundup/admin.py:1419
+msgid "roundup> "
+msgstr "roundup> "
+
+#: ../roundup/admin.py:1421
+msgid "exit..."
+msgstr "išeiti..."
+
+#: ../roundup/admin.py:1431
+msgid "There are unsaved changes. Commit them (y/N)? "
+msgstr "Yra neišsaugotų pakeitimų. Išsaugoti juos (y/N)? "
+
+#: ../roundup/backends/back_anydbm.py:2000
+#, python-format
+msgid "WARNING: invalid date tuple %r"
+msgstr "PERSPĖJIMAS: netinkamas datos tuple'as %r"
+
+#: ../roundup/backends/rdbms_common.py:1442
+msgid "create"
+msgstr "sukurti"
+
+#: ../roundup/backends/rdbms_common.py:1608
+msgid "unlink"
+msgstr "atsieti"
+
+#: ../roundup/backends/rdbms_common.py:1612
+msgid "link"
+msgstr "susieti"
+
+#: ../roundup/backends/rdbms_common.py:1732
+msgid "set"
+msgstr "nustatyti"
+
+#: ../roundup/backends/rdbms_common.py:1756
+msgid "retired"
+msgstr "deaktyvuotas"
+
+#: ../roundup/backends/rdbms_common.py:1786
+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
+#: ../roundup/cgi/actions.py:174: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
+#: ../roundup/cgi/actions.py:180:209
+msgid "You do not have permission to store queries"
+msgstr "Neturite leidimo išsaugoti užklausas"
+
+#: ../roundup/cgi/actions.py:298
+#, python-format
+msgid "Not enough values on line %(line)s"
+msgstr "Nepakanka reikšmių eilutėje %(line)s"
+
+#: ../roundup/cgi/actions.py:345
+msgid "Items edited OK"
+msgstr "Elementų pakeitimai išsaugoti"
+
+#: ../roundup/cgi/actions.py:405
+#, 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:408
+#, python-format
+msgid "%(class)s %(id)s - nothing changed"
+msgstr "%(class)s %(id)s - niekas nepakeista"
+
+#: ../roundup/cgi/actions.py:420
+#, python-format
+msgid "%(class)s %(id)s created"
+msgstr "sukurta %(class)s %(id)s"
+
+#: ../roundup/cgi/actions.py:452
+#, python-format
+msgid "You do not have permission to edit %(class)s"
+msgstr "Neturite leidimo redaguoti %(class)s"
+
+#: ../roundup/cgi/actions.py:464
+#, python-format
+msgid "You do not have permission to create %(class)s"
+msgstr "Neturite leidimo sukurti %(class)s"
+
+#: ../roundup/cgi/actions.py:488
+msgid "You do not have permission to edit user roles"
+msgstr "Neturite leidimo redaguoti vartotojų roles"
+
+#: ../roundup/cgi/actions.py:538
+#, 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:566
+#, python-format
+msgid "Edit Error: %s"
+msgstr "Redagavimo klaida: %s"
+
+# ../roundup/cgi/actions.py:579 :590 :761 :780
+#: ../roundup/cgi/actions.py:597
+#: ../roundup/cgi/actions.py:608
+#: ../roundup/cgi/actions.py:779
+#: ../roundup/cgi/actions.py:798
+#: ../roundup/cgi/actions.py:597:608
+#: :779:798
+#, python-format
+msgid "Error: %s"
+msgstr "Klaida: %s"
+
+#: ../roundup/cgi/actions.py:634
+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:676
+#, 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:685
+msgid "Unknown username"
+msgstr "Nežinomas vartotojo vardas"
+
+#: ../roundup/cgi/actions.py:693
+msgid "Unknown email address"
+msgstr "Nežinomas el. pašto adresas"
+
+#: ../roundup/cgi/actions.py:698
+msgid "You need to specify a username or address"
+msgstr "Privalote nurodyti vartotojo vardą ar el. pašto adresą"
+
+#: ../roundup/cgi/actions.py:723
+#, python-format
+msgid "Email sent to %s"
+msgstr "El. laiškas išsiųstas %s"
+
+#: ../roundup/cgi/actions.py:742
+msgid "You are now registered, welcome!"
+msgstr "Jūs esate užregistruotas, sveiki prisijungę!"
+
+#: ../roundup/cgi/actions.py:787
+msgid "It is not permitted to supply roles at registration."
+msgstr "Negalima pateikti rolių registracijos metu."
+
+#: ../roundup/cgi/actions.py:879
+msgid "You are logged out"
+msgstr "Jūs atsijungėte"
+
+#: ../roundup/cgi/actions.py:896
+msgid "Username required"
+msgstr "Reikalingas vartotojo vardas"
+
+# ../roundup/cgi/actions.py:897 :901
+#: ../roundup/cgi/actions.py:931
+#: ../roundup/cgi/actions.py:935
+#: ../roundup/cgi/actions.py:931:935
+msgid "Invalid login"
+msgstr "Neteisingas vartotojo vardas ar slaptažodis"
+
+#: ../roundup/cgi/actions.py:941
+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
+#: ../roundup/cgi/cgitb.py:172: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:326
+msgid "Form Error: "
+msgstr "Formos klaida: "
+
+#: ../roundup/cgi/client.py:381
+#, python-format
+msgid "Unrecognized charset: %r"
+msgstr "Neatpažinta koduotė: %r"
+
+#: ../roundup/cgi/client.py:509
+msgid "Anonymous users are not allowed to use the web interface"
+msgstr "Anoniminiai vartotojai neturi teisių naudoti web interfeisą"
+
+#: ../roundup/cgi/client.py:664
+msgid "You are not allowed to view this file."
+msgstr "Jūs neturite teisių žiūrėti šį failą."
+
+#: ../roundup/cgi/client.py:758
+#, 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:762
+#, 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 \"%(entry)s\" not a designator"
+msgstr "sąsajos \"%(key)s\" reikšmė \"%(entry)s\" nėra dezignatorius"
+
+#: ../roundup/cgi/form_parser.py:301
+#, 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:313
+#, python-format
+msgid "The form action claims to require property \"%(property)s\" which doesn't exist"
+msgstr "Formos veiksmas reikalauja parinkties \"%(property)s\", kuri neegzistuoja"
+
+#: ../roundup/cgi/form_parser.py:335
+#, 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:354
+#: ../roundup/cgi/form_parser.py:380
+#: ../roundup/cgi/form_parser.py:354:380
+#, 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:377
+#: ../roundup/cgi/form_parser.py:383
+#: ../roundup/cgi/form_parser.py:377:383
+msgid "Password and confirmation text do not match"
+msgstr "Slaptažodis ir patvirtinimo tekstas neatitinka"
+
+#: ../roundup/cgi/form_parser.py:418
+#, 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:535
+#, 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:558
+msgid "File is empty"
+msgstr "Failas tuščias"
+
+#: ../roundup/cgi/templating.py:73
+#, 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:643
+msgid "(list)"
+msgstr "(list)"
+
+#: ../roundup/cgi/templating.py:712
+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:726
+#: ../roundup/cgi/templating.py:860
+#: ../roundup/cgi/templating.py:1267
+#: ../roundup/cgi/templating.py:1296
+#: ../roundup/cgi/templating.py:1316
+#: ../roundup/cgi/templating.py:1365
+#: ../roundup/cgi/templating.py:1388
+#: ../roundup/cgi/templating.py:1424
+#: ../roundup/cgi/templating.py:1463
+#: ../roundup/cgi/templating.py:1516
+#: ../roundup/cgi/templating.py:1533
+#: ../roundup/cgi/templating.py:1618
+#: ../roundup/cgi/templating.py:1638
+#: ../roundup/cgi/templating.py:1656
+#: ../roundup/cgi/templating.py:1688
+#: ../roundup/cgi/templating.py:1698
+#: ../roundup/cgi/templating.py:1752
+#: ../roundup/cgi/templating.py:1945
+#: ../roundup/cgi/templating.py:726:860
+#: :1267:1296
+#: :1316:1365
+#: :1388:1424
+#: :1463:1516
+#: :1533:1618
+#: :1638:1656
+#: :1688:1698
+#: :1752:1945
+msgid "[hidden]"
+msgstr "[paslėpta]"
+
+#: ../roundup/cgi/templating.py:727
+msgid "New node - no history"
+msgstr "Naujas elementas -- nėra istorijos"
+
+#: ../roundup/cgi/templating.py:842
+msgid "Submit Changes"
+msgstr "Išsaugoti pakeitimus"
+
+#: ../roundup/cgi/templating.py:924
+msgid "<em>The indicated property no longer exists</em>"
+msgstr "<em>Nurodytos parinkties nėra</em>"
+
+#: ../roundup/cgi/templating.py:925
+#, python-format
+msgid "<em>%s: %s</em>\n"
+msgstr "<em>%s: %s</em>\n"
+
+#: ../roundup/cgi/templating.py:938
+#, 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:971
+#: ../roundup/cgi/templating.py:995
+#: ../roundup/cgi/templating.py:971:995
+msgid "<strike>The linked node no longer exists</strike>"
+msgstr "<strike>Susieto elemento nebėra</strike>"
+
+#: ../roundup/cgi/templating.py:1048
+#, python-format
+msgid "%s: (no value)"
+msgstr "%s: (no value)"
+
+#: ../roundup/cgi/templating.py:1060
+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:1072
+msgid "<tr><td colspan=4><strong>Note:</strong></td></tr>"
+msgstr "<tr><td colspan=4><strong>Pastaba:</strong></td></tr>"
+
+#: ../roundup/cgi/templating.py:1081
+msgid "History"
+msgstr "Archyvas"
+
+#: ../roundup/cgi/templating.py:1083
+msgid "<th>Date</th>"
+msgstr "<th>Data</th>"
+
+#: ../roundup/cgi/templating.py:1084
+msgid "<th>User</th>"
+msgstr "<th>Vartotojas</th>"
+
+#: ../roundup/cgi/templating.py:1085
+msgid "<th>Action</th>"
+msgstr "<th>Veiksmas</th>"
+
+#: ../roundup/cgi/templating.py:1086
+msgid "<th>Args</th>"
+msgstr "<th>Argumentai</th>"
+
+#: ../roundup/cgi/templating.py:1128
+#, python-format
+msgid "Copy of %(class)s %(id)s"
+msgstr "%(class)s %(id)s kopija"
+
+#: ../roundup/cgi/templating.py:1392
+msgid "*encrypted*"
+msgstr "*užkoduota*"
+
+# ../roundup/cgi/templating.py:993 :1357 :1378 :1384
+#: ../roundup/cgi/templating.py:1467
+#: ../roundup/cgi/templating.py:1488
+#: ../roundup/cgi/templating.py:1494
+#: ../roundup/cgi/templating.py:1037:1467
+#: :1488:1494
+msgid "No"
+msgstr "Ne"
+
+# ../roundup/cgi/templating.py:993 :1357 :1376 :1381
+#: ../roundup/cgi/templating.py:1467
+#: ../roundup/cgi/templating.py:1486
+#: ../roundup/cgi/templating.py:1491
+#: ../roundup/cgi/templating.py:1037:1467
+#: :1486:1491
+msgid "Yes"
+msgstr "Taip"
+
+#: ../roundup/cgi/templating.py:1580
+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:1743
+#, 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:1820
+#, python-format
+msgid "<option %svalue=\"-1\">- no selection -</option>"
+msgstr "<option %svalue=\"-1\">- nepasirinkta -</option>"
+
+#: ../roundup/date.py:301
+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:363
+#, 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:662
+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:681
+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:818
+#, python-format
+msgid "%(number)s year"
+msgid_plural "%(number)s years"
+msgstr[0] "%(number)s metus"
+msgstr[1] "%(number)s metus"
+msgstr[2] "%(number)s metų"
+
+#: ../roundup/date.py:822
+#, python-format
+msgid "%(number)s month"
+msgid_plural "%(number)s months"
+msgstr[0] "%(number)s mėnesį"
+msgstr[1] "%(number)s mėnesius"
+msgstr[2] "%(number)s mėnesių"
+
+#: ../roundup/date.py:826
+#, python-format
+msgid "%(number)s week"
+msgid_plural "%(number)s weeks"
+msgstr[0] "%(number)s savaitę"
+msgstr[1] "%(number)s savaites"
+msgstr[2] "%(number)s savaičių"
+
+#: ../roundup/date.py:830
+#, python-format
+msgid "%(number)s day"
+msgid_plural "%(number)s days"
+msgstr[0] "%(number)s dieną"
+msgstr[1] "%(number)s dienas"
+msgstr[2] "%(number)s dienų"
+
+#: ../roundup/date.py:834
+msgid "tomorrow"
+msgstr "rytoj"
+
+#: ../roundup/date.py:836
+msgid "yesterday"
+msgstr "vakar"
+
+#: ../roundup/date.py:839
+#, python-format
+msgid "%(number)s hour"
+msgid_plural "%(number)s hours"
+msgstr[0] "%(number)s valandą"
+msgstr[1] "%(number)s valandas"
+msgstr[2] "%(number)s valandų"
+
+#: ../roundup/date.py:843
+msgid "an hour"
+msgstr "valandą"
+
+#: ../roundup/date.py:845
+msgid "1 1/2 hours"
+msgstr "1 1/2 valandos"
+
+#: ../roundup/date.py:847
+#, 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:851
+msgid "in a moment"
+msgstr "už minutės"
+
+#: ../roundup/date.py:853
+msgid "just now"
+msgstr "ką tik"
+
+#: ../roundup/date.py:856
+msgid "1 minute"
+msgstr "1 minutę"
+
+#: ../roundup/date.py:859
+#, python-format
+msgid "%(number)s minute"
+msgid_plural "%(number)s minutes"
+msgstr[0] "%(number)s minutę"
+msgstr[1] "%(number)s minutes"
+msgstr[2] "%(number)s minučių"
+
+#: ../roundup/date.py:862
+msgid "1/2 an hour"
+msgstr "1/2 valandos"
+
+#: ../roundup/date.py:864
+#, 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:868
+#, python-format
+msgid "%s ago"
+msgstr "prieš %s"
+
+#: ../roundup/date.py:870
+#, 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/mailgw.py:574
+msgid ""
+"\n"
+"Emails to Roundup trackers must include a Subject: line!\n"
+msgstr ""
+"\n"
+"Laiškai Roundup'o tracker'iams privalo turėti temos (Subject:) eilutę!\n"
+
+#: ../roundup/mailgw.py:664
+#, python-format
+msgid ""
+"\n"
+"The message you sent to roundup did not contain a properly formed subject\n"
+"line. The subject must contain a class name or designator to indicate the\n"
+"'topic' of the message. For example:\n"
+"    Subject: [issue] This is a new issue\n"
+"      - this will create a new issue in the tracker with the title 'This is\n"
+"        a new issue'.\n"
+"    Subject: [issue1234] This is a followup to issue 1234\n"
+"      - this will append the message's contents to the existing issue 1234\n"
+"        in the tracker.\n"
+"\n"
+"Subject was: '%(subject)s'\n"
+msgstr ""
+"\n"
+"Jūsų siųstas laiškas roundup'ui neturi teisingai suformuotos temos (Subject)\n"
+"eilutės. Tema privalo turėti klasės vardą arba dezignatorių. Pavyzdžiui:\n"
+"    Tema: [issue] Tai yra naujas kreipinys\n"
+"      - tai sukurs naują kreipinį pavadinimu 'Tai yra naujas kreipinys'.\n"
+"    Tema: [issue1234] Tai yra kreipinio 1234 tęsinys\n"
+"      - tai pridės laiško turinį prie esančio kreipinio 1234.\n"
+"\n"
+"Tema buvo: '%(subject)s'\n"
+
+#: ../roundup/mailgw.py:695
+#, python-format
+msgid ""
+"\n"
+"The class name you identified in the subject line (\"%(classname)s\") does not exist in the\n"
+"database.\n"
+"\n"
+"Valid class names are: %(validname)s\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"\n"
+"Klasės (\"%(classname)s\"), kurią jūs nurodėte temos eilutėje, nėra duomenų\n"
+"bazėje.\n"
+"\n"
+"Teisingi klasių vardai yra: %(validname)s\n"
+"Tema buvo: \"%(subject)s\"\n"
+
+#: ../roundup/mailgw.py:730
+#, python-format
+msgid ""
+"\n"
+"I cannot match your message to a node in the database - you need to either\n"
+"supply a full designator (with number, eg \"[issue123]\" or keep the\n"
+"previous subject title intact so I can match that.\n"
+"\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"\n"
+"Negaliu priskirti jūsų laiško jokiam duomenų bazės elementui - jūs turite\n"
+"nurodyti pilną dezignatorių (su numeriu, pvz., \"[issue123]\" arba palikite\n"
+"temos pavadinimą neliestą.\n"
+"\n"
+"Tema buvo: \"%(subject)s\"\n"
+
+#: ../roundup/mailgw.py:763
+#, python-format
+msgid ""
+"\n"
+"The node specified by the designator in the subject of your message\n"
+"(\"%(nodeid)s\") does not exist.\n"
+"\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"\n"
+"Elemento (\"%(nodeid)s\"), nurodyto dezignatoriumi temos eilutėje,\n"
+"nėra.\n"
+"\n"
+"Tema buvo: \"%(subject)s\"\n"
+
+#: ../roundup/mailgw.py:791
+#, python-format
+msgid ""
+"\n"
+"The mail gateway is not properly set up. Please contact\n"
+"%(mailadmin)s and have them fix the incorrect class specified as:\n"
+"  %(current_class)s\n"
+msgstr ""
+"\n"
+"Nesukonfigūruotas pašto sietuvas (mail gateway). Paprašykite\n"
+"%(mailadmin)s, kad pataisytų neteisingą klasės pavadinimą:\n"
+"  %(current_class)s\n"
+
+#: ../roundup/mailgw.py:814
+#, python-format
+msgid ""
+"\n"
+"The mail gateway is not properly set up. Please contact\n"
+"%(mailadmin)s and have them fix the incorrect properties:\n"
+"  %(errors)s\n"
+msgstr ""
+"\n"
+"Nesukonfigūruotas pašto sietuvas (mail gateway). Paprašykite\n"
+"%(mailadmin)s, kad pataisytų neteisingus atributus:\n"
+"  %(errors)s\n"
+
+#: ../roundup/mailgw.py:844
+#, python-format
+msgid ""
+"\n"
+"You are not a registered user.\n"
+"\n"
+"Unknown address: %(from_address)s\n"
+msgstr ""
+"\n"
+"Jūs nesate registruotas vartotojas.\n"
+"\n"
+"Nežinomas adresas: %(from_address)s\n"
+
+#: ../roundup/mailgw.py:852
+msgid "You are not permitted to access this tracker."
+msgstr "Neturite teisių naudotis šiuo tracker'iu."
+
+#: ../roundup/mailgw.py:859
+#, python-format
+msgid "You are not permitted to edit %(classname)s."
+msgstr "Neturite leidimo redaguoti %(classname)s."
+
+#: ../roundup/mailgw.py:863
+#, python-format
+msgid "You are not permitted to create %(classname)s."
+msgstr "Neturite leidimo sukurti %(classname)s."
+
+#: ../roundup/mailgw.py:910
+#, python-format
+msgid ""
+"\n"
+"There were problems handling your subject line argument list:\n"
+"- %(errors)s\n"
+"\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"\n"
+"Temos eilutės argumentų sąraše yra klaidų:\n"
+"- %(errors)s\n"
+"\n"
+"Tema buvo: \"%(subject)s\"\n"
+
+#: ../roundup/mailgw.py:938
+msgid ""
+"\n"
+"Roundup requires the submission to be plain text. The message parser could\n"
+"not find a text/plain part to use.\n"
+msgstr ""
+"\n"
+"Roundup'ui reikia, kad laiškas būtų tekstinis. Laiškų analizatorius nerado\n"
+"text/plain dalies.\n"
+
+#: ../roundup/mailgw.py:960
+msgid "You are not permitted to create files."
+msgstr "Neturite teisių sukurti failą."
+
+#: ../roundup/mailgw.py:974
+#, python-format
+msgid "You are not permitted to add files to %(classname)s."
+msgstr "Neturite leidimo pridėti failų prie %(classname)s."
+
+#: ../roundup/mailgw.py:992
+msgid "You are not permitted to create messages."
+msgstr "Neturite leidimo sukurti pranešimų."
+
+#: ../roundup/mailgw.py:1000
+#, python-format
+msgid ""
+"\n"
+"Mail message was rejected by a detector.\n"
+"%(error)s\n"
+msgstr ""
+"\n"
+"Detektorius atmetė jūsų laišką.\n"
+"%(error)s\n"
+
+#: ../roundup/mailgw.py:1008
+#, python-format
+msgid "You are not permitted to add messages to %(classname)s."
+msgstr "Neturite leidimo pridėti pranešimų prie %(classname)s."
+
+#: ../roundup/mailgw.py:1035
+#, python-format
+msgid "You are not permitted to edit property %(prop)s of class %(classname)s."
+msgstr "Neturite leidimo redaguoti %(classname)s parinkties %(prop)s."
+
+#: ../roundup/mailgw.py:1043
+#, python-format
+msgid ""
+"\n"
+"There was a problem with the message you sent:\n"
+"   %(message)s\n"
+msgstr ""
+"\n"
+"Jūsų laiške %(message)s yra klaidų.\n"
+
+#: ../roundup/mailgw.py:1065
+msgid "not of form [arg=value,value,...;arg=value,value,...]"
+msgstr "ne tokios formos: [arg=reikšmė,reikšmė,...;arg=reikšmė,reikšmė,...]"
+
+#: ../roundup/roundupdb.py:146
+msgid "files"
+msgstr "failai"
+
+#: ../roundup/roundupdb.py:146
+msgid "messages"
+msgstr "pranešimai"
+
+#: ../roundup/roundupdb.py:146
+msgid "nosy"
+msgstr "informuoti"
+
+#: ../roundup/roundupdb.py:146
+msgid "superseder"
+msgstr "pirmtakas"
+
+#: ../roundup/roundupdb.py:146
+msgid "title"
+msgstr "antraštė"
+
+#: ../roundup/roundupdb.py:147
+msgid "assignedto"
+msgstr "priskirta"
+
+#: ../roundup/roundupdb.py:147
+msgid "priority"
+msgstr "prioritetas"
+
+#: ../roundup/roundupdb.py:147
+msgid "status"
+msgstr "statusas"
+
+#: ../roundup/roundupdb.py:147
+msgid "topic"
+msgstr "tema"
+
+#: ../roundup/roundupdb.py:150
+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:150
+msgid "actor"
+msgstr "veikėjas"
+
+#: ../roundup/roundupdb.py:150
+msgid "creation"
+msgstr "sukūrimas"
+
+#: ../roundup/roundupdb.py:150
+msgid "creator"
+msgstr "kūrėjas"
+
+#: ../roundup/roundupdb.py:308
+#, python-format
+msgid "New submission from %(authname)s%(authaddr)s:"
+msgstr "Nauja pateiktis nuo %(authname)s%(authaddr)s:"
+
+#: ../roundup/roundupdb.py:311
+#, python-format
+msgid "%(authname)s%(authaddr)s added the comment:"
+msgstr "%(authname)s%(authaddr)s parašė komentarą:"
+
+#: ../roundup/roundupdb.py:314
+msgid "System message:"
+msgstr "Sistemos pranešimas:"
+
+#: ../roundup/roundupdb.py:597
+#, python-format
+msgid ""
+"\n"
+"Now:\n"
+"%s\n"
+"Was:\n"
+"%s"
+msgstr ""
+"\n"
+"Dabar:\n"
+"%s\n"
+"Buvo:\n"
+"%s"
+
+#: ../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 class] [[-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@server\n"
+" The username and password may be omitted:\n"
+"    pop username@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@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@server\n"
+" It also allows you to specify a specific mailbox other than INBOX using\n"
+" this format:\n"
+"    imap username:password@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@server [mailbox]\n"
+"\n"
+msgstr ""
+"Naudojimas: %(program)s [-v] [-c klasė] [[-C klasė] -S laukas=reikšmė]* <namų direktorija> [metodas]\n"
+"\n"
+"Parinktys:\n"
+" -v: išspausdinti versiją 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ė[;parinktis=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@serveris\n"
+" Vartotojo vardas ir slaptažodis gali būti praleisti:\n"
+"    pop vartotojas@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@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@serveris\n"
+" Tai taip pat leidžia nurodyti kitą pašto dėžutę nei INBOX naudojant šį\n"
+" formatą:\n"
+"    imap vartotojas:slaptažodis@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@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:293
+#, python-format
+msgid "Error: %s: %s"
+msgstr "Klaida: %s: %s"
+
+#: ../roundup/scripts/roundup_server.py:303
+msgid "WARNING: ignoring \"-g\" argument, not root"
+msgstr "PERSPĖJIMAS: \"-g\" argumentas ignoruojamas, nėra root teisių"
+
+#: ../roundup/scripts/roundup_server.py:309
+msgid "Can't change groups - no grp module"
+msgstr "Negaliu pakeisti grupių -- nėra grp modulio"
+
+#: ../roundup/scripts/roundup_server.py:318
+#, python-format
+msgid "Group %(group)s doesn't exist"
+msgstr "Grupės %(group)s nėra"
+
+#: ../roundup/scripts/roundup_server.py:329
+msgid "Can't run as root!"
+msgstr "Negaliu paleisti root teisėmis!"
+
+#: ../roundup/scripts/roundup_server.py:332
+msgid "WARNING: ignoring \"-u\" argument, not root"
+msgstr "PERSPĖJIMAS: \"-u\" argumentas ignoruojamas, nėra root teisių"
+
+#: ../roundup/scripts/roundup_server.py:338
+msgid "Can't change users - no pwd module"
+msgstr "Negaliu pakesiti vartotojų - nėra pwd modulio"
+
+#: ../roundup/scripts/roundup_server.py:347
+#, python-format
+msgid "User %(user)s doesn't exist"
+msgstr "Vartotojo %(user)s nėra"
+
+#: ../roundup/scripts/roundup_server.py:478
+#, 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:501
+#, 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:569
+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:576
+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:583
+#, 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:730
+msgid "Instances must be name=home"
+msgstr "Egzempliorius turi būti nurodomas taip: vardas=namų_direktorija"
+
+#: ../roundup/scripts/roundup_server.py:744
+#, python-format
+msgid "Configuration saved to %s"
+msgstr "Konfigūracija išsaugota %s"
+
+#: ../roundup/scripts/roundup_server.py:762
+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:774
+#, 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-empty.html:6
+msgid "Please specify your search parameters!"
+msgstr "Prašome nurodyti paieškos parametrus!"
+
+#: ../templates/classic/html/_generic.help-list.html:20
+#: ../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:35
+#: ../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:35
+#: ../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.help-list.html:34
+msgid "1..25 out of 50"
+msgstr "1..25 iš 50"
+
+#: ../templates/classic/html/_generic.help-search.html:9
+msgid "Generic template ${template} or version for class ${classname} is not yet implemented"
+msgstr "Bendras šablonas ${template} arba versija klasei ${classname} nėra paruošta"
+
+#: ../templates/classic/html/_generic.help-submit.html:57
+#: ../templates/classic/html/_generic.help.html:31
+#: ../templates/minimal/html/_generic.help.html:31
+msgid " Cancel "
+msgstr " Atšaukti "
+
+#: ../templates/classic/html/_generic.help-submit.html:63
+#: ../templates/classic/html/_generic.help.html:34
+#: ../templates/minimal/html/_generic.help.html:34
+msgid " Apply "
+msgstr " Vykdyti "
+
+#: ../templates/classic/html/_generic.help.html:9
+#: ../templates/classic/html/user.help.html:13
+#: ../templates/minimal/html/_generic.help.html:9
+msgid "${property} help - ${tracker}"
+msgstr "${property} pagalba - ${tracker}"
+
+#: ../templates/classic/html/_generic.help.html:41
+#: ../templates/classic/html/help.html:21
+#: ../templates/classic/html/issue.index.html:80
+#: ../templates/minimal/html/_generic.help.html:41
+msgid "&lt;&lt; previous"
+msgstr "&lt;&lt; pirmesnis"
+
+#: ../templates/classic/html/_generic.help.html:53
+#: ../templates/classic/html/help.html:28
+#: ../templates/classic/html/issue.index.html:88
+#: ../templates/minimal/html/_generic.help.html:53
+msgid "${start}..${end} out of ${total}"
+msgstr "${start}..${end} iš ${total}"
+
+#: ../templates/classic/html/_generic.help.html:57
+#: ../templates/classic/html/help.html:32
+#: ../templates/classic/html/issue.index.html:91
+#: ../templates/minimal/html/_generic.help.html:57
+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:19
+#: ../templates/classic/html/_generic.item.html:16
+#: ../templates/classic/html/file.item.html:13
+#: ../templates/classic/html/issue.index.html:20
+#: ../templates/classic/html/issue.item.html:32
+#: ../templates/classic/html/msg.item.html:30
+#: ../templates/classic/html/user.index.html:13
+#: ../templates/classic/html/user.item.html:39
+#: ../templates/minimal/html/_generic.index.html:19
+#: ../templates/minimal/html/_generic.item.html:17
+#: ../templates/minimal/html/user.index.html:13
+#: ../templates/minimal/html/user.register.html:17
+msgid "Please login with your username and password."
+msgstr "Prašome įvesti Jums priskirtą vardą ir slaptažodį."
+
+#: ../templates/classic/html/_generic.index.html:28
+#: ../templates/minimal/html/_generic.index.html:28
+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:50
+#: ../templates/minimal/html/_generic.index.html:50
+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:27
+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:48
+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:23
+#: ../templates/classic/html/user.register.html:17
+msgid "Name"
+msgstr "Vardas"
+
+#: ../templates/classic/html/file.item.html:45
+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:4
+#: ../templates/classic/html/issue.index.html:10
+msgid "List of issues"
+msgstr "Kreipinių sąrašas"
+
+#: ../templates/classic/html/issue.index.html:27
+#: ../templates/classic/html/issue.item.html:49
+msgid "Priority"
+msgstr "Prioritetas"
+
+#: ../templates/classic/html/issue.index.html:28
+msgid "ID"
+msgstr "ID"
+
+#: ../templates/classic/html/issue.index.html:29
+msgid "Creation"
+msgstr "Sukūrimas"
+
+#: ../templates/classic/html/issue.index.html:30
+msgid "Activity"
+msgstr "Veikla"
+
+#: ../templates/classic/html/issue.index.html:31
+msgid "Actor"
+msgstr "Veikėjas"
+
+#: ../templates/classic/html/issue.index.html:32
+msgid "Topic"
+msgstr "Tema"
+
+#: ../templates/classic/html/issue.index.html:33
+#: ../templates/classic/html/issue.item.html:44
+msgid "Title"
+msgstr "Pavadinimas"
+
+#: ../templates/classic/html/issue.index.html:34
+#: ../templates/classic/html/issue.item.html:51
+msgid "Status"
+msgstr "Statusas"
+
+#: ../templates/classic/html/issue.index.html:35
+msgid "Creator"
+msgstr "Sukūrėjas"
+
+#: ../templates/classic/html/issue.index.html:36
+msgid "Assigned&nbsp;To"
+msgstr "Priskirta"
+
+#: ../templates/classic/html/issue.index.html:104
+msgid "Download as CSV"
+msgstr "Parsisiųsti kaip CSV"
+
+#: ../templates/classic/html/issue.index.html:114
+msgid "Sort on:"
+msgstr "Išrūšiuoti pagal:"
+
+#: ../templates/classic/html/issue.index.html:118
+#: ../templates/classic/html/issue.index.html:139
+msgid "- nothing -"
+msgstr "- nieko -"
+
+#: ../templates/classic/html/issue.index.html:126
+#: ../templates/classic/html/issue.index.html:147
+msgid "Descending:"
+msgstr "Mažėjančia tvarka:"
+
+#: ../templates/classic/html/issue.index.html:135
+msgid "Group on:"
+msgstr "Grupuoti pagal:"
+
+#: ../templates/classic/html/issue.index.html:154
+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:56
+msgid "Superseder"
+msgstr "Pirmtakas"
+
+#: ../templates/classic/html/issue.item.html:61
+msgid "View:"
+msgstr "Rodyti:"
+
+#: ../templates/classic/html/issue.item.html:67
+msgid "Nosy List"
+msgstr "Sąrašas informuoti"
+
+#: ../templates/classic/html/issue.item.html:76
+msgid "Assigned To"
+msgstr "Priskirta"
+
+#: ../templates/classic/html/issue.item.html:78
+msgid "Topics"
+msgstr "Temos"
+
+#: ../templates/classic/html/issue.item.html:86
+msgid "Change Note"
+msgstr "Pakeitimų pastabos"
+
+#: ../templates/classic/html/issue.item.html:94
+msgid "File"
+msgstr "Failas"
+
+#: ../templates/classic/html/issue.item.html:106
+msgid "Make a copy"
+msgstr "Kopijuoti"
+
+#: ../templates/classic/html/issue.item.html:114
+#: ../templates/classic/html/user.item.html:152
+#: ../templates/classic/html/user.register.html:69
+#: ../templates/minimal/html/user.item.html:147
+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:128
+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:132
+#: ../templates/classic/html/msg.item.html:61
+msgid "Files"
+msgstr "Failai"
+
+#: ../templates/classic/html/issue.item.html:134
+#: ../templates/classic/html/msg.item.html:63
+msgid "File name"
+msgstr "Failo vardas"
+
+#: ../templates/classic/html/issue.item.html:135
+#: ../templates/classic/html/msg.item.html:64
+msgid "Uploaded"
+msgstr "Nusiųsta"
+
+#: ../templates/classic/html/issue.item.html:136
+msgid "Type"
+msgstr "Tipas"
+
+#: ../templates/classic/html/issue.item.html:137
+#: ../templates/classic/html/query.edit.html:30
+msgid "Edit"
+msgstr "Redaguoti"
+
+#: ../templates/classic/html/issue.item.html:138
+msgid "Remove"
+msgstr "Pašalinti"
+
+#: ../templates/classic/html/issue.item.html:158
+#: ../templates/classic/html/issue.item.html:179
+#: ../templates/classic/html/query.edit.html:50
+msgid "remove"
+msgstr "pašalinti"
+
+#: ../templates/classic/html/issue.item.html:165
+#: ../templates/classic/html/msg.index.html:9
+msgid "Messages"
+msgstr "Pranešimai"
+
+#: ../templates/classic/html/issue.item.html:169
+msgid "msg${id} (view)"
+msgstr "msg${id} (view)"
+
+#: ../templates/classic/html/issue.item.html:170
+msgid "Author: ${author}"
+msgstr "Autorius: ${author}"
+
+#: ../templates/classic/html/issue.item.html:172
+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:31
+msgid "Filter on"
+msgstr "Filtruoti pagal"
+
+#: ../templates/classic/html/issue.search.html:32
+msgid "Display"
+msgstr "Parodyti"
+
+#: ../templates/classic/html/issue.search.html:33
+msgid "Sort on"
+msgstr "Rūšiuoti pagal"
+
+#: ../templates/classic/html/issue.search.html:34
+msgid "Group on"
+msgstr "Grupuoti pagal"
+
+#: ../templates/classic/html/issue.search.html:38
+msgid "All text*:"
+msgstr "Visas tekstas*:"
+
+#: ../templates/classic/html/issue.search.html:46
+msgid "Title:"
+msgstr "Pavadinimas:"
+
+#: ../templates/classic/html/issue.search.html:56
+msgid "Topic:"
+msgstr "Tema:"
+
+#: ../templates/classic/html/issue.search.html:64
+msgid "ID:"
+msgstr "ID:"
+
+#: ../templates/classic/html/issue.search.html:72
+msgid "Creation Date:"
+msgstr "Sukūrimo data:"
+
+#: ../templates/classic/html/issue.search.html:83
+msgid "Creator:"
+msgstr "Kūrėjas:"
+
+#: ../templates/classic/html/issue.search.html:85
+msgid "created by me"
+msgstr "mano sukurta"
+
+#: ../templates/classic/html/issue.search.html:94
+msgid "Activity:"
+msgstr "Veikla:"
+
+#: ../templates/classic/html/issue.search.html:105
+msgid "Actor:"
+msgstr "Veikėjas:"
+
+#: ../templates/classic/html/issue.search.html:107
+msgid "done by me"
+msgstr "mano atlikta"
+
+#: ../templates/classic/html/issue.search.html:118
+msgid "Priority:"
+msgstr "Prioritetas:"
+
+#: ../templates/classic/html/issue.search.html:120
+#: ../templates/classic/html/issue.search.html:136
+msgid "not selected"
+msgstr "nepasirinkta"
+
+#: ../templates/classic/html/issue.search.html:131
+msgid "Status:"
+msgstr "Statusas:"
+
+#: ../templates/classic/html/issue.search.html:134
+msgid "not resolved"
+msgstr "neišspręsta"
+
+#: ../templates/classic/html/issue.search.html:149
+msgid "Assigned to:"
+msgstr "Priskirta:"
+
+#: ../templates/classic/html/issue.search.html:152
+msgid "assigned to me"
+msgstr "priskirta man"
+
+#: ../templates/classic/html/issue.search.html:154
+msgid "unassigned"
+msgstr "nepriskirta"
+
+#: ../templates/classic/html/issue.search.html:164
+msgid "No Sort or group:"
+msgstr "Nėra rūšiavimo ar grupavimo:"
+
+#: ../templates/classic/html/issue.search.html:172
+msgid "Pagesize:"
+msgstr "Puslapio dydis:"
+
+#: ../templates/classic/html/issue.search.html:178
+msgid "Start With:"
+msgstr "Pradėti nuo:"
+
+#: ../templates/classic/html/issue.search.html:184
+msgid "Sort Descending:"
+msgstr "Rūšiuoti mažėjančia tvarka:"
+
+#: ../templates/classic/html/issue.search.html:191
+msgid "Group Descending:"
+msgstr "Grupuoti mažėjančia tvarka:"
+
+#: ../templates/classic/html/issue.search.html:198
+msgid "Query name**:"
+msgstr "Užklausos vardas**:"
+
+#: ../templates/classic/html/issue.search.html:210
+#: ../templates/classic/html/page.html:43
+#: ../templates/classic/html/page.html:92
+#: ../templates/classic/html/user.help-search.html:69
+#: ../templates/minimal/html/page.html:43
+#: ../templates/minimal/html/page.html:91
+msgid "Search"
+msgstr "Paieška"
+
+#: ../templates/classic/html/issue.search.html:215
+msgid "*: The \"all text\" field will look in message bodies and issue titles"
+msgstr "*: Laukas „visas tekstas“ ieškos pranešimų tekste ir kreipinių pavadinimuose"
+
+#: ../templates/classic/html/issue.search.html:218
+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:38
+msgid "Author"
+msgstr "Autorius"
+
+#: ../templates/classic/html/msg.item.html:43
+msgid "Recipients"
+msgstr "Gavėjai"
+
+#: ../templates/classic/html/msg.item.html:54
+msgid "Content"
+msgstr "Turinys"
+
+#: ../templates/classic/html/page.html:54
+#: ../templates/minimal/html/page.html:53
+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:65
+#: ../templates/minimal/html/page.html:64
+msgid "Issues"
+msgstr "Kreipiniai"
+
+#: ../templates/classic/html/page.html:67
+#: ../templates/classic/html/page.html:105
+#: ../templates/minimal/html/page.html:66
+#: ../templates/minimal/html/page.html:104
+msgid "Create New"
+msgstr "Sukurti naują"
+
+#: ../templates/classic/html/page.html:69
+#: ../templates/minimal/html/page.html:68
+msgid "Show Unassigned"
+msgstr "Rodyti nepriskirtus"
+
+#: ../templates/classic/html/page.html:81
+#: ../templates/minimal/html/page.html:80
+msgid "Show All"
+msgstr "Rodyti visus"
+
+#: ../templates/classic/html/page.html:93
+#: ../templates/minimal/html/page.html:92
+msgid "Show issue:"
+msgstr "Rodyti kreipinį:"
+
+#: ../templates/classic/html/page.html:103
+#: ../templates/minimal/html/page.html:102
+msgid "Keywords"
+msgstr "Raktiniai žodžiai"
+
+#: ../templates/classic/html/page.html:108
+#: ../templates/minimal/html/page.html:107
+msgid "Edit Existing"
+msgstr "Redaguoti esamus"
+
+#: ../templates/classic/html/page.html:114
+#: ../templates/minimal/html/page.html:113
+msgid "Administration"
+msgstr "Administravimas"
+
+#: ../templates/classic/html/page.html:116
+#: ../templates/minimal/html/page.html:115
+msgid "Class List"
+msgstr "Klasių sąrašas"
+
+#: ../templates/classic/html/page.html:120
+#: ../templates/minimal/html/page.html:119
+msgid "User List"
+msgstr "Vartotojų sąrašas"
+
+#: ../templates/classic/html/page.html:122
+#: ../templates/minimal/html/page.html:121
+msgid "Add User"
+msgstr "Pridėti vartotoją"
+
+#: ../templates/classic/html/page.html:129
+#: ../templates/classic/html/page.html:135
+#: ../templates/minimal/html/page.html:128
+#: ../templates/minimal/html/page.html:134
+msgid "Login"
+msgstr "Prisijungti"
+
+#: ../templates/classic/html/page.html:134
+#: ../templates/minimal/html/page.html:133
+msgid "Remember me?"
+msgstr "Prisiminti mane?"
+
+#: ../templates/classic/html/page.html:138
+#: ../templates/classic/html/user.register.html:63
+#: ../templates/minimal/html/page.html:137
+#: ../templates/minimal/html/user.register.html:61
+msgid "Register"
+msgstr "Užsiregistruoti"
+
+#: ../templates/classic/html/page.html:141
+#: ../templates/minimal/html/page.html:140
+msgid "Lost&nbsp;your&nbsp;login?"
+msgstr "Pamiršote&nbsp;savo&nbsp;vartotojo&nbsp;vardą?"
+
+#: ../templates/classic/html/page.html:146
+#: ../templates/minimal/html/page.html:145
+msgid "Hello, ${user}"
+msgstr "Sveiki, ${user}"
+
+#: ../templates/classic/html/page.html:148
+msgid "Your Issues"
+msgstr "Jūsų kreipiniai"
+
+#: ../templates/classic/html/page.html:160
+#: ../templates/minimal/html/page.html:147
+msgid "Your Details"
+msgstr "Smulkesnė informacija apie jus"
+
+#: ../templates/classic/html/page.html:162
+#: ../templates/minimal/html/page.html:149
+msgid "Logout"
+msgstr "Atsijungti"
+
+#: ../templates/classic/html/page.html:166
+#: ../templates/minimal/html/page.html:153
+msgid "Help"
+msgstr "Pagalba"
+
+#: ../templates/classic/html/page.html:167
+#: ../templates/minimal/html/page.html:154
+msgid "Roundup docs"
+msgstr "Roundup dokumentacija"
+
+#: ../templates/classic/html/page.html:177
+#: ../templates/minimal/html/page.html:164
+msgid "clear this message"
+msgstr "išvalyti šį pranešimą"
+
+#: ../templates/classic/html/page.html:241
+#: ../templates/classic/html/page.html:256
+#: ../templates/classic/html/page.html:270
+#: ../templates/minimal/html/page.html:228
+#: ../templates/minimal/html/page.html:243
+#: ../templates/minimal/html/page.html:257
+msgid "don't care"
+msgstr "nesvarbu"
+
+#: ../templates/classic/html/page.html:243
+#: ../templates/classic/html/page.html:258
+#: ../templates/classic/html/page.html:271
+#: ../templates/minimal/html/page.html:230
+#: ../templates/minimal/html/page.html:245
+#: ../templates/minimal/html/page.html:258
+msgid "------------"
+msgstr "------------"
+
+#: ../templates/classic/html/page.html:299
+#: ../templates/minimal/html/page.html:286
+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.help-search.html:73
+msgid "Pagesize"
+msgstr "Puslapio dydis"
+
+#: ../templates/classic/html/user.help.html:43
+msgid "Your browser is not capable of using frames; you should be redirected immediately, or visit ${link}."
+msgstr "Jūsų naršyklė nepalaiko rėmelių (frames); būsite nukreipti dabar arba paspauskite ${link}."
+
+#: ../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:19
+#: ../templates/minimal/html/user.index.html:19
+msgid "Username"
+msgstr "Vartotojo vardas"
+
+#: ../templates/classic/html/user.index.html:20
+msgid "Real name"
+msgstr "Tikras vardas"
+
+#: ../templates/classic/html/user.index.html:21
+#: ../templates/classic/html/user.register.html:45
+msgid "Organisation"
+msgstr "Organizacija"
+
+#: ../templates/classic/html/user.index.html:22
+#: ../templates/minimal/html/user.index.html:20
+msgid "Email address"
+msgstr "El. pašto adresas"
+
+#: ../templates/classic/html/user.index.html:23
+msgid "Phone number"
+msgstr "Telefono numeris"
+
+#: ../templates/classic/html/user.index.html:24
+msgid "Retire"
+msgstr "Deaktyvuoti"
+
+#: ../templates/classic/html/user.index.html:37
+msgid "retire"
+msgstr "deaktyvuoti"
+
+#: ../templates/classic/html/user.item.html:9
+#: ../templates/minimal/html/user.item.html:9
+msgid "User ${id}: ${title} - ${tracker}"
+msgstr "Vartotojas ${id}: ${title} - ${tracker}"
+
+#: ../templates/classic/html/user.item.html:12
+#: ../templates/minimal/html/user.item.html:12
+msgid "New User - ${tracker}"
+msgstr "Naujas vartotojas - ${tracker}"
+
+#: ../templates/classic/html/user.item.html:21
+#: ../templates/minimal/html/user.item.html:21
+msgid "New User"
+msgstr "Naujas vartotojas"
+
+#: ../templates/classic/html/user.item.html:23
+#: ../templates/minimal/html/user.item.html:23
+msgid "New User Editing"
+msgstr "Naujo vartotojo redagavimas"
+
+#: ../templates/classic/html/user.item.html:26
+#: ../templates/minimal/html/user.item.html:26
+msgid "User${id}"
+msgstr "Vartotojas${id}"
+
+#: ../templates/classic/html/user.item.html:29
+#: ../templates/minimal/html/user.item.html:29
+msgid "User${id} Editing"
+msgstr "Vartotojo${id} redagavimas"
+
+#: ../templates/classic/html/user.item.html:79
+#: ../templates/classic/html/user.register.html:33
+#: ../templates/minimal/html/user.item.html:74
+#: ../templates/minimal/html/user.register.html:41
+msgid "Roles"
+msgstr "Rolės"
+
+#: ../templates/classic/html/user.item.html:87
+#: ../templates/minimal/html/user.item.html:82
+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:108
+#: ../templates/minimal/html/user.item.html:103
+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:129
+#: ../templates/classic/html/user.register.html:53
+#: ../templates/minimal/html/user.item.html:124
+#: ../templates/minimal/html/user.register.html:53
+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.register.html:21
+#: ../templates/minimal/html/user.register.html:29
+msgid "Login Name"
+msgstr "Prisijungimo vardas"
+
+#: ../templates/classic/html/user.register.html:25
+#: ../templates/minimal/html/user.register.html:33
+msgid "Login Password"
+msgstr "Prisijungimo slaptažodis"
+
+#: ../templates/classic/html/user.register.html:29
+#: ../templates/minimal/html/user.register.html:37
+msgid "Confirm Password"
+msgstr "Patvirtinti slaptažodį"
+
+#: ../templates/classic/html/user.register.html:41
+msgid "Phone"
+msgstr "Telefonas"
+
+#: ../templates/classic/html/user.register.html:49
+#: ../templates/minimal/html/user.register.html:49
+msgid "E-mail address"
+msgstr "El. pašto adresas"
+
+#: ../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/classic/initial_data.py:5
+msgid "critical"
+msgstr "kritinis"
+
+#: ../templates/classic/initial_data.py:6
+msgid "urgent"
+msgstr "skubus"
+
+#: ../templates/classic/initial_data.py:7
+msgid "bug"
+msgstr "klaida"
+
+#: ../templates/classic/initial_data.py:8
+msgid "feature"
+msgstr "nauja savybė"
+
+#: ../templates/classic/initial_data.py:9
+msgid "wish"
+msgstr "pageidavimas"
+
+#: ../templates/classic/initial_data.py:12
+msgid "unread"
+msgstr "neskaityta"
+
+#: ../templates/classic/initial_data.py:13
+msgid "deferred"
+msgstr "atidėta"
+
+#: ../templates/classic/initial_data.py:14
+msgid "chatting"
+msgstr "vyksta pokalbis"
+
+#: ../templates/classic/initial_data.py:15
+msgid "need-eg"
+msgstr "reikia pvz."
+
+#: ../templates/classic/initial_data.py:16
+msgid "in-progress"
+msgstr "eigoje"
+
+#: ../templates/classic/initial_data.py:17
+msgid "testing"
+msgstr "testuojama"
+
+#: ../templates/classic/initial_data.py:18
+msgid "done-cbb"
+msgstr "atlikta - galėtų būti geriau"
+
+#: ../templates/classic/initial_data.py:19
+msgid "resolved"
+msgstr "išspręsta"
+
+#: ../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."
diff --git a/locale/roundup.pot b/locale/roundup.pot
new file mode 100644 (file)
index 0000000..8f646a6
--- /dev/null
@@ -0,0 +1,3020 @@
+# 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@ADDRESS>, YEAR.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\n"
+"Report-Msgid-Bugs-To: roundup-devel@lists.sourceforge.net\n"
+"POT-Creation-Date: 2007-09-27 11:18+0300\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@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:86 ../roundup/admin.py:989 ../roundup/admin.py:1040
+#: ../roundup/admin.py:1063 ../roundup/admin.py:86:989 :1040:1063
+#, python-format
+msgid "no such class \"%(classname)s\""
+msgstr ""
+
+#: ../roundup/admin.py:96 ../roundup/admin.py:100 ../roundup/admin.py:96:100
+#, python-format
+msgid "argument \"%(arg)s\" not propname=value"
+msgstr ""
+
+#: ../roundup/admin.py:113
+#, python-format
+msgid ""
+"Problem: %(message)s\n"
+"\n"
+msgstr ""
+
+#: ../roundup/admin.py:114
+#, 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"
+" -V                -- be verbose when importing\n"
+" -v                -- report Roundup and Python versions (and quit)\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:141
+msgid "Commands:"
+msgstr ""
+
+#: ../roundup/admin.py:148
+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:178
+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:241
+#, python-format
+msgid "%s:"
+msgstr ""
+
+#: ../roundup/admin.py:246
+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:269
+#, python-format
+msgid "Sorry, no help for \"%(topic)s\""
+msgstr ""
+
+#: ../roundup/admin.py:346 ../roundup/admin.py:402 ../roundup/admin.py:346:402
+msgid "Templates:"
+msgstr ""
+
+#: ../roundup/admin.py:349 ../roundup/admin.py:413 ../roundup/admin.py:349:413
+msgid "Back ends:"
+msgstr ""
+
+#: ../roundup/admin.py:352
+msgid ""
+"Usage: install [template [backend [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 and backend may be specified on the command-line\n"
+"        as arguments, in that order.\n"
+"\n"
+"        Command line arguments following the backend allows you to\n"
+"        pass initial values 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:375 ../roundup/admin.py:472 ../roundup/admin.py:533
+#: ../roundup/admin.py:612 ../roundup/admin.py:663 ../roundup/admin.py:721
+#: ../roundup/admin.py:742 ../roundup/admin.py:770 ../roundup/admin.py:842
+#: ../roundup/admin.py:909 ../roundup/admin.py:980 ../roundup/admin.py:1030
+#: ../roundup/admin.py:1053 ../roundup/admin.py:1084 ../roundup/admin.py:1180
+#: ../roundup/admin.py:1253 ../roundup/admin.py:375:472 :1030:1053 :1084:1180
+#: :1253 :533:612 :663:721 :742:770 :842:909 :980
+msgid "Not enough arguments supplied"
+msgstr ""
+
+#: ../roundup/admin.py:381
+#, python-format
+msgid "Instance home parent directory \"%(parent)s\" does not exist"
+msgstr ""
+
+#: ../roundup/admin.py:389
+#, 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:404
+msgid "Select template [classic]: "
+msgstr ""
+
+#: ../roundup/admin.py:415
+msgid "Select backend [anydbm]: "
+msgstr ""
+
+#: ../roundup/admin.py:425
+#, python-format
+msgid "Error in configuration settings: \"%s\""
+msgstr ""
+
+#: ../roundup/admin.py:434
+#, python-format
+msgid ""
+"\n"
+"---------------------------------------------------------------------------\n"
+" You should now edit the tracker configuration file:\n"
+"   %(config_file)s"
+msgstr ""
+
+#: ../roundup/admin.py:444
+msgid " ... at a minimum, you must set following options:"
+msgstr ""
+
+#: ../roundup/admin.py:449
+#, 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:467
+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:477
+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:491
+msgid "Admin Password: "
+msgstr ""
+
+#: ../roundup/admin.py:492
+msgid "       Confirm: "
+msgstr ""
+
+#: ../roundup/admin.py:496
+msgid "Instance home does not exist"
+msgstr ""
+
+#: ../roundup/admin.py:500
+msgid "Instance has not been installed"
+msgstr ""
+
+#: ../roundup/admin.py:505
+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:526
+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:566 ../roundup/admin.py:581 ../roundup/admin.py:566:581
+#, python-format
+msgid "property %s is not of type Multilink or Link so -d flag does not apply."
+msgstr ""
+
+#: ../roundup/admin.py:589 ../roundup/admin.py:991 ../roundup/admin.py:1042
+#: ../roundup/admin.py:1065 ../roundup/admin.py:589:991 :1042:1065
+#, python-format
+msgid "no such %(classname)s node \"%(nodeid)s\""
+msgstr ""
+
+#: ../roundup/admin.py:591
+#, python-format
+msgid "no such %(classname)s property \"%(propname)s\""
+msgstr ""
+
+#: ../roundup/admin.py:600
+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:655
+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:708 ../roundup/admin.py:862 ../roundup/admin.py:874
+#: ../roundup/admin.py:928 ../roundup/admin.py:708:862 :874:928
+#, python-format
+msgid "%(classname)s has no property \"%(propname)s\""
+msgstr ""
+
+#: ../roundup/admin.py:715
+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:730
+#, python-format
+msgid "%(key)s: %(value)s (key property)"
+msgstr ""
+
+#: ../roundup/admin.py:732 ../roundup/admin.py:759 ../roundup/admin.py:732:759
+#, python-format
+msgid "%(key)s: %(value)s"
+msgstr ""
+
+#: ../roundup/admin.py:735
+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:762
+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:789
+#, python-format
+msgid "%(propname)s (Password): "
+msgstr ""
+
+#: ../roundup/admin.py:791
+#, python-format
+msgid "   %(propname)s (Again): "
+msgstr ""
+
+#: ../roundup/admin.py:793
+msgid "Sorry, try again..."
+msgstr ""
+
+#: ../roundup/admin.py:797
+#, python-format
+msgid "%(propname)s (%(proptype)s): "
+msgstr ""
+
+#: ../roundup/admin.py:815
+#, python-format
+msgid "you must provide the \"%(propname)s\" property."
+msgstr ""
+
+#: ../roundup/admin.py:827
+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:840
+msgid "Too many arguments supplied"
+msgstr ""
+
+#: ../roundup/admin.py:876
+#, python-format
+msgid "%(nodeid)4s: %(value)s"
+msgstr ""
+
+#: ../roundup/admin.py:880
+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:924
+#, python-format
+msgid "\"%(spec)s\" not name:width"
+msgstr ""
+
+#: ../roundup/admin.py:974
+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:995
+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:1010
+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:1023
+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:1047
+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:1070
+msgid ""
+"Usage: export [[-]class[,class]] export_dir\n"
+"        Export the database to colon-separated-value files.\n"
+"        To exclude the files (e.g. for the msg or file class),\n"
+"        use the exporttables command.\n"
+"\n"
+"        Optionally limit the export to just the named classes\n"
+"        or exclude the named classes, if the 1st argument starts with '-'.\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:1145
+msgid ""
+"Usage: exporttables [[-]class[,class]] export_dir\n"
+"        Export the database to colon-separated-value files, excluding the\n"
+"        files below $TRACKER_HOME/db/files/ (which can be archived "
+"separately).\n"
+"        To include the files, use the export command.\n"
+"\n"
+"        Optionally limit the export to just the named classes\n"
+"        or exclude the named classes, if the 1st argument starts with '-'.\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:1160
+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:1235
+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:1263
+msgid "Invalid format"
+msgstr ""
+
+#: ../roundup/admin.py:1274
+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:1288
+#, python-format
+msgid "no such item \"%(designator)s\""
+msgstr ""
+
+#: ../roundup/admin.py:1298
+msgid ""
+"Usage: security [Role name]\n"
+"        Display the Permissions available to one or all Roles.\n"
+"        "
+msgstr ""
+
+#: ../roundup/admin.py:1306
+#, python-format
+msgid "No such Role \"%(role)s\""
+msgstr ""
+
+#: ../roundup/admin.py:1312
+#, python-format
+msgid "New Web users get the Roles \"%(role)s\""
+msgstr ""
+
+#: ../roundup/admin.py:1314
+#, python-format
+msgid "New Web users get the Role \"%(role)s\""
+msgstr ""
+
+#: ../roundup/admin.py:1317
+#, python-format
+msgid "New Email users get the Roles \"%(role)s\""
+msgstr ""
+
+#: ../roundup/admin.py:1319
+#, python-format
+msgid "New Email users get the Role \"%(role)s\""
+msgstr ""
+
+#: ../roundup/admin.py:1322
+#, python-format
+msgid "Role \"%(name)s\":"
+msgstr ""
+
+#: ../roundup/admin.py:1327
+#, python-format
+msgid " %(description)s (%(name)s for \"%(klass)s\": %(properties)s only)"
+msgstr ""
+
+#: ../roundup/admin.py:1330
+#, python-format
+msgid " %(description)s (%(name)s for \"%(klass)s\" only)"
+msgstr ""
+
+#: ../roundup/admin.py:1333
+#, python-format
+msgid " %(description)s (%(name)s)"
+msgstr ""
+
+#: ../roundup/admin.py:1362
+#, python-format
+msgid "Unknown command \"%(command)s\" (\"help commands\" for a list)"
+msgstr ""
+
+#: ../roundup/admin.py:1368
+#, python-format
+msgid "Multiple commands match \"%(command)s\": %(list)s"
+msgstr ""
+
+#: ../roundup/admin.py:1375
+msgid "Enter tracker home: "
+msgstr ""
+
+#: ../roundup/admin.py:1382 ../roundup/admin.py:1388 ../roundup/admin.py:1408
+#: ../roundup/admin.py:1382:1388 :1408
+#, python-format
+msgid "Error: %(message)s"
+msgstr ""
+
+#: ../roundup/admin.py:1396
+#, python-format
+msgid "Error: Couldn't open tracker: %(message)s"
+msgstr ""
+
+#: ../roundup/admin.py:1421
+#, python-format
+msgid ""
+"Roundup %s ready for input.\n"
+"Type \"help\" for help."
+msgstr ""
+
+#: ../roundup/admin.py:1426
+msgid "Note: command history and editing not available"
+msgstr ""
+
+#: ../roundup/admin.py:1430
+msgid "roundup> "
+msgstr ""
+
+#: ../roundup/admin.py:1432
+msgid "exit..."
+msgstr ""
+
+#: ../roundup/admin.py:1442
+msgid "There are unsaved changes. Commit them (y/N)? "
+msgstr ""
+
+#: ../roundup/backends/back_anydbm.py:219
+#: ../roundup/backends/sessions_dbm.py:50
+msgid "Couldn't identify database type"
+msgstr ""
+
+#: ../roundup/backends/back_anydbm.py:245
+#, python-format
+msgid "Couldn't open database - the required module '%s' is not available"
+msgstr ""
+
+#: ../roundup/backends/back_anydbm.py:795
+#: ../roundup/backends/back_anydbm.py:1070
+#: ../roundup/backends/back_anydbm.py:1267
+#: ../roundup/backends/back_anydbm.py:1285
+#: ../roundup/backends/back_anydbm.py:1331
+#: ../roundup/backends/back_anydbm.py:1901
+#: ../roundup/backends/back_anydbm.py:795:1070
+#: ../roundup/backends/back_metakit.py:567
+#: ../roundup/backends/back_metakit.py:834
+#: ../roundup/backends/back_metakit.py:866
+#: ../roundup/backends/back_metakit.py:1601
+#: ../roundup/backends/back_metakit.py:567:834
+#: ../roundup/backends/rdbms_common.py:1320
+#: ../roundup/backends/rdbms_common.py:1549
+#: ../roundup/backends/rdbms_common.py:1755
+#: ../roundup/backends/rdbms_common.py:1775
+#: ../roundup/backends/rdbms_common.py:1828
+#: ../roundup/backends/rdbms_common.py:2436
+#: ../roundup/backends/rdbms_common.py:1320:1549 :1267:1285 :1331:1901
+#: :1755:1775 :1828:2436 :866:1601
+msgid "Database open read-only"
+msgstr ""
+
+#: ../roundup/backends/back_anydbm.py:2003
+#, python-format
+msgid "WARNING: invalid date tuple %r"
+msgstr ""
+
+#: ../roundup/backends/rdbms_common.py:1449
+msgid "create"
+msgstr ""
+
+#: ../roundup/backends/rdbms_common.py:1615
+msgid "unlink"
+msgstr ""
+
+#: ../roundup/backends/rdbms_common.py:1619
+msgid "link"
+msgstr ""
+
+#: ../roundup/backends/rdbms_common.py:1741
+msgid "set"
+msgstr ""
+
+#: ../roundup/backends/rdbms_common.py:1765
+msgid "retired"
+msgstr ""
+
+#: ../roundup/backends/rdbms_common.py:1795
+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:169 ../roundup/cgi/actions.py:197
+#: ../roundup/cgi/actions.py:169:197
+msgid "You do not have permission to edit queries"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:175 ../roundup/cgi/actions.py:204
+#: ../roundup/cgi/actions.py:175:204
+msgid "You do not have permission to store queries"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:310
+#, python-format
+msgid "Not enough values on line %(line)s"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:357
+msgid "Items edited OK"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:416
+#, python-format
+msgid "%(class)s %(id)s %(properties)s edited ok"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:419
+#, python-format
+msgid "%(class)s %(id)s - nothing changed"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:431
+#, python-format
+msgid "%(class)s %(id)s created"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:463
+#, python-format
+msgid "You do not have permission to edit %(class)s"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:475
+#, python-format
+msgid "You do not have permission to create %(class)s"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:499
+msgid "You do not have permission to edit user roles"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:549
+#, 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:577
+#, python-format
+msgid "Edit Error: %s"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:608 ../roundup/cgi/actions.py:619
+#: ../roundup/cgi/actions.py:790 ../roundup/cgi/actions.py:809
+#: ../roundup/cgi/actions.py:608:619 :790:809
+#, python-format
+msgid "Error: %s"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:645
+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:687
+#, python-format
+msgid "Password reset and email sent to %s"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:696
+msgid "Unknown username"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:704
+msgid "Unknown email address"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:709
+msgid "You need to specify a username or address"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:734
+#, python-format
+msgid "Email sent to %s"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:753
+msgid "You are now registered, welcome!"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:798
+msgid "It is not permitted to supply roles at registration."
+msgstr ""
+
+#: ../roundup/cgi/actions.py:890
+msgid "You are logged out"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:907
+msgid "Username required"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:942 ../roundup/cgi/actions.py:946
+#: ../roundup/cgi/actions.py:942:946
+msgid "Invalid login"
+msgstr ""
+
+#: ../roundup/cgi/actions.py:952
+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 ../roundup/cgi/cgitb.py:178
+#: ../roundup/cgi/cgitb.py:172:178
+msgid "<em>undefined</em>"
+msgstr ""
+
+#: ../roundup/cgi/client.py:51
+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:377
+msgid "Form Error: "
+msgstr ""
+
+#: ../roundup/cgi/client.py:432
+#, python-format
+msgid "Unrecognized charset: %r"
+msgstr ""
+
+#: ../roundup/cgi/client.py:560
+msgid "Anonymous users are not allowed to use the web interface"
+msgstr ""
+
+#: ../roundup/cgi/client.py:715
+msgid "You are not allowed to view this file."
+msgstr ""
+
+#: ../roundup/cgi/client.py:808
+#, python-format
+msgid "%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n"
+msgstr ""
+
+#: ../roundup/cgi/client.py:812
+#, 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 \"%(entry)s\" not a designator"
+msgstr ""
+
+#: ../roundup/cgi/form_parser.py:301
+#, python-format
+msgid "%(class)s %(property)s is not a link or multilink property"
+msgstr ""
+
+#: ../roundup/cgi/form_parser.py:313
+#, python-format
+msgid ""
+"The form action claims to require property \"%(property)s\" which doesn't "
+"exist"
+msgstr ""
+
+#: ../roundup/cgi/form_parser.py:335
+#, 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:354 ../roundup/cgi/form_parser.py:380
+#: ../roundup/cgi/form_parser.py:354:380
+#, python-format
+msgid "You have submitted more than one value for the %s property"
+msgstr ""
+
+#: ../roundup/cgi/form_parser.py:377 ../roundup/cgi/form_parser.py:383
+#: ../roundup/cgi/form_parser.py:377:383
+msgid "Password and confirmation text do not match"
+msgstr ""
+
+#: ../roundup/cgi/form_parser.py:418
+#, python-format
+msgid "property \"%(propname)s\": \"%(value)s\" not currently in list"
+msgstr ""
+
+#: ../roundup/cgi/form_parser.py:551
+#, 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:574
+msgid "File is empty"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:77
+#, python-format
+msgid "You are not allowed to %(action)s items of class %(class)s"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:657
+msgid "(list)"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:726
+msgid "Submit New Entry"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:740 ../roundup/cgi/templating.py:873
+#: ../roundup/cgi/templating.py:1294 ../roundup/cgi/templating.py:1323
+#: ../roundup/cgi/templating.py:1343 ../roundup/cgi/templating.py:1356
+#: ../roundup/cgi/templating.py:1407 ../roundup/cgi/templating.py:1430
+#: ../roundup/cgi/templating.py:1466 ../roundup/cgi/templating.py:1503
+#: ../roundup/cgi/templating.py:1556 ../roundup/cgi/templating.py:1573
+#: ../roundup/cgi/templating.py:1657 ../roundup/cgi/templating.py:1677
+#: ../roundup/cgi/templating.py:1695 ../roundup/cgi/templating.py:1727
+#: ../roundup/cgi/templating.py:1737 ../roundup/cgi/templating.py:1789
+#: ../roundup/cgi/templating.py:1978 ../roundup/cgi/templating.py:740:873
+#: :1294:1323 :1343:1356 :1407:1430 :1466:1503 :1556:1573 :1657:1677 :1695:1727
+#: :1737:1789 :1978
+msgid "[hidden]"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:741
+msgid "New node - no history"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:855
+msgid "Submit Changes"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:937
+msgid "<em>The indicated property no longer exists</em>"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:938
+#, python-format
+msgid "<em>%s: %s</em>\n"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:951
+#, python-format
+msgid "The linked class %(classname)s no longer exists"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:984 ../roundup/cgi/templating.py:1008
+#: ../roundup/cgi/templating.py:984:1008
+msgid "<strike>The linked node no longer exists</strike>"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1061
+#, python-format
+msgid "%s: (no value)"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1073
+msgid ""
+"<strong><em>This event is not handled by the history display!</em></strong>"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1085
+msgid "<tr><td colspan=4><strong>Note:</strong></td></tr>"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1094
+msgid "History"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1096
+msgid "<th>Date</th>"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1097
+msgid "<th>User</th>"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1098
+msgid "<th>Action</th>"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1099
+msgid "<th>Args</th>"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1141
+#, python-format
+msgid "Copy of %(class)s %(id)s"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1434
+msgid "*encrypted*"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1507 ../roundup/cgi/templating.py:1528
+#: ../roundup/cgi/templating.py:1534 ../roundup/cgi/templating.py:1050:1507
+#: :1528:1534
+msgid "No"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1507 ../roundup/cgi/templating.py:1526
+#: ../roundup/cgi/templating.py:1531 ../roundup/cgi/templating.py:1050:1507
+#: :1526:1531
+msgid "Yes"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1620
+msgid ""
+"default value for DateHTMLProperty must be either DateHTMLProperty or string "
+"date representation."
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1780
+#, python-format
+msgid "Attempt to look up %(attr)s on a missing value"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:1853
+#, python-format
+msgid "<option %svalue=\"-1\">- no selection -</option>"
+msgstr ""
+
+#: ../roundup/date.py:300
+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:359
+#, 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:666
+msgid ""
+"Not an interval spec: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS] [date spec]"
+msgstr ""
+
+#: ../roundup/date.py:685
+msgid "Not an interval spec: [+-] [#y] [#m] [#w] [#d] [[[H]H:MM]:SS]"
+msgstr ""
+
+#: ../roundup/date.py:822
+#, python-format
+msgid "%(number)s year"
+msgid_plural "%(number)s years"
+msgstr[0] ""
+msgstr[1] ""
+
+#: ../roundup/date.py:826
+#, python-format
+msgid "%(number)s month"
+msgid_plural "%(number)s months"
+msgstr[0] ""
+msgstr[1] ""
+
+#: ../roundup/date.py:830
+#, python-format
+msgid "%(number)s week"
+msgid_plural "%(number)s weeks"
+msgstr[0] ""
+msgstr[1] ""
+
+#: ../roundup/date.py:834
+#, python-format
+msgid "%(number)s day"
+msgid_plural "%(number)s days"
+msgstr[0] ""
+msgstr[1] ""
+
+#: ../roundup/date.py:838
+msgid "tomorrow"
+msgstr ""
+
+#: ../roundup/date.py:840
+msgid "yesterday"
+msgstr ""
+
+#: ../roundup/date.py:843
+#, python-format
+msgid "%(number)s hour"
+msgid_plural "%(number)s hours"
+msgstr[0] ""
+msgstr[1] ""
+
+#: ../roundup/date.py:847
+msgid "an hour"
+msgstr ""
+
+#: ../roundup/date.py:849
+msgid "1 1/2 hours"
+msgstr ""
+
+#: ../roundup/date.py:851
+#, python-format
+msgid "1 %(number)s/4 hours"
+msgid_plural "1 %(number)s/4 hours"
+msgstr[0] ""
+msgstr[1] ""
+
+#: ../roundup/date.py:855
+msgid "in a moment"
+msgstr ""
+
+#: ../roundup/date.py:857
+msgid "just now"
+msgstr ""
+
+#: ../roundup/date.py:860
+msgid "1 minute"
+msgstr ""
+
+#: ../roundup/date.py:863
+#, python-format
+msgid "%(number)s minute"
+msgid_plural "%(number)s minutes"
+msgstr[0] ""
+msgstr[1] ""
+
+#: ../roundup/date.py:866
+msgid "1/2 an hour"
+msgstr ""
+
+#: ../roundup/date.py:868
+#, python-format
+msgid "%(number)s/4 hour"
+msgid_plural "%(number)s/4 hours"
+msgstr[0] ""
+msgstr[1] ""
+
+#: ../roundup/date.py:872
+#, python-format
+msgid "%s ago"
+msgstr ""
+
+#: ../roundup/date.py:874
+#, python-format
+msgid "in %s"
+msgstr ""
+
+#: ../roundup/hyperdb.py:87
+#, python-format
+msgid "property %s: %s"
+msgstr ""
+
+#: ../roundup/hyperdb.py:107
+#, python-format
+msgid "property %s: %r is an invalid date (%s)"
+msgstr ""
+
+#: ../roundup/hyperdb.py:124
+#, python-format
+msgid "property %s: %r is an invalid date interval (%s)"
+msgstr ""
+
+#: ../roundup/hyperdb.py:219
+#, python-format
+msgid "property %s: %r is not currently an element"
+msgstr ""
+
+#: ../roundup/hyperdb.py:263
+#, python-format
+msgid "property %s: %r is not a number"
+msgstr ""
+
+#: ../roundup/hyperdb.py:276
+#, python-format
+msgid "\"%s\" not a node designator"
+msgstr ""
+
+#: ../roundup/hyperdb.py:949 ../roundup/hyperdb.py:957
+#: ../roundup/hyperdb.py:949:957
+#, python-format
+msgid "Not a property name: %s"
+msgstr ""
+
+#: ../roundup/hyperdb.py:1240
+#, python-format
+msgid "property %s: %r is not a %s."
+msgstr ""
+
+#: ../roundup/hyperdb.py:1243
+#, python-format
+msgid "you may only enter ID values for property %s"
+msgstr ""
+
+#: ../roundup/hyperdb.py:1273
+#, python-format
+msgid "%r is not a property of %s"
+msgstr ""
+
+#: ../roundup/init.py:134
+#, python-format
+msgid ""
+"WARNING: directory '%s'\n"
+"\tcontains old-style template - ignored"
+msgstr ""
+
+#: ../roundup/mailgw.py:199 ../roundup/mailgw.py:211
+#: ../roundup/mailgw.py:199:211
+#, python-format
+msgid "Message signed with unknown key: %s"
+msgstr ""
+
+#: ../roundup/mailgw.py:202
+#, python-format
+msgid "Message signed with an expired key: %s"
+msgstr ""
+
+#: ../roundup/mailgw.py:205
+#, python-format
+msgid "Message signed with a revoked key: %s"
+msgstr ""
+
+#: ../roundup/mailgw.py:208
+msgid "Invalid PGP signature detected."
+msgstr ""
+
+#: ../roundup/mailgw.py:404
+msgid "Unknown multipart/encrypted version."
+msgstr ""
+
+#: ../roundup/mailgw.py:413
+msgid "Unable to decrypt your message."
+msgstr ""
+
+#: ../roundup/mailgw.py:442
+msgid "No PGP signature found in message."
+msgstr ""
+
+#: ../roundup/mailgw.py:749
+msgid ""
+"\n"
+"Emails to Roundup trackers must include a Subject: line!\n"
+msgstr ""
+
+#: ../roundup/mailgw.py:873
+#, python-format
+msgid ""
+"\n"
+"The message you sent to roundup did not contain a properly formed subject\n"
+"line. The subject must contain a class name or designator to indicate the\n"
+"'topic' of the message. For example:\n"
+"    Subject: [issue] This is a new issue\n"
+"      - this will create a new issue in the tracker with the title 'This is\n"
+"        a new issue'.\n"
+"    Subject: [issue1234] This is a followup to issue 1234\n"
+"      - this will append the message's contents to the existing issue 1234\n"
+"        in the tracker.\n"
+"\n"
+"Subject was: '%(subject)s'\n"
+msgstr ""
+
+#: ../roundup/mailgw.py:911
+#, python-format
+msgid ""
+"\n"
+"The class name you identified in the subject line (\"%(classname)s\") does\n"
+"not exist in the database.\n"
+"\n"
+"Valid class names are: %(validname)s\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+
+#: ../roundup/mailgw.py:919
+#, python-format
+msgid ""
+"\n"
+"You did not identify a class name in the subject line and there is no\n"
+"default set for this tracker. The subject must contain a class name or\n"
+"designator to indicate the 'topic' of the message. For example:\n"
+"    Subject: [issue] This is a new issue\n"
+"      - this will create a new issue in the tracker with the title 'This is\n"
+"        a new issue'.\n"
+"    Subject: [issue1234] This is a followup to issue 1234\n"
+"      - this will append the message's contents to the existing issue 1234\n"
+"        in the tracker.\n"
+"\n"
+"Subject was: '%(subject)s'\n"
+msgstr ""
+
+#: ../roundup/mailgw.py:960
+#, python-format
+msgid ""
+"\n"
+"I cannot match your message to a node in the database - you need to either\n"
+"supply a full designator (with number, eg \"[issue123]\") or keep the\n"
+"previous subject title intact so I can match that.\n"
+"\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+
+#: ../roundup/mailgw.py:993
+#, python-format
+msgid ""
+"\n"
+"The node specified by the designator in the subject of your message\n"
+"(\"%(nodeid)s\") does not exist.\n"
+"\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+
+#: ../roundup/mailgw.py:1021
+#, python-format
+msgid ""
+"\n"
+"The mail gateway is not properly set up. Please contact\n"
+"%(mailadmin)s and have them fix the incorrect class specified as:\n"
+"  %(current_class)s\n"
+msgstr ""
+
+#: ../roundup/mailgw.py:1044
+#, python-format
+msgid ""
+"\n"
+"The mail gateway is not properly set up. Please contact\n"
+"%(mailadmin)s and have them fix the incorrect properties:\n"
+"  %(errors)s\n"
+msgstr ""
+
+#: ../roundup/mailgw.py:1084
+#, python-format
+msgid ""
+"\n"
+"You are not a registered user.%(registration_info)s\n"
+"\n"
+"Unknown address: %(from_address)s\n"
+msgstr ""
+
+#: ../roundup/mailgw.py:1092
+msgid "You are not permitted to access this tracker."
+msgstr ""
+
+#: ../roundup/mailgw.py:1099
+#, python-format
+msgid "You are not permitted to edit %(classname)s."
+msgstr ""
+
+#: ../roundup/mailgw.py:1103
+#, python-format
+msgid "You are not permitted to create %(classname)s."
+msgstr ""
+
+#: ../roundup/mailgw.py:1150
+#, python-format
+msgid ""
+"\n"
+"There were problems handling your subject line argument list:\n"
+"- %(errors)s\n"
+"\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+
+#: ../roundup/mailgw.py:1203
+msgid ""
+"\n"
+"This tracker has been configured to require all email be PGP signed or\n"
+"encrypted."
+msgstr ""
+
+#: ../roundup/mailgw.py:1209
+msgid ""
+"\n"
+"Roundup requires the submission to be plain text. The message parser could\n"
+"not find a text/plain part to use.\n"
+msgstr ""
+
+#: ../roundup/mailgw.py:1226
+msgid "You are not permitted to create files."
+msgstr ""
+
+#: ../roundup/mailgw.py:1240
+#, python-format
+msgid "You are not permitted to add files to %(classname)s."
+msgstr ""
+
+#: ../roundup/mailgw.py:1258
+msgid "You are not permitted to create messages."
+msgstr ""
+
+#: ../roundup/mailgw.py:1266
+#, python-format
+msgid ""
+"\n"
+"Mail message was rejected by a detector.\n"
+"%(error)s\n"
+msgstr ""
+
+#: ../roundup/mailgw.py:1274
+#, python-format
+msgid "You are not permitted to add messages to %(classname)s."
+msgstr ""
+
+#: ../roundup/mailgw.py:1301
+#, python-format
+msgid "You are not permitted to edit property %(prop)s of class %(classname)s."
+msgstr ""
+
+#: ../roundup/mailgw.py:1309
+#, python-format
+msgid ""
+"\n"
+"There was a problem with the message you sent:\n"
+"   %(message)s\n"
+msgstr ""
+
+#: ../roundup/mailgw.py:1331
+msgid "not of form [arg=value,value,...;arg=value,value,...]"
+msgstr ""
+
+#: ../roundup/roundupdb.py:147
+msgid "files"
+msgstr ""
+
+#: ../roundup/roundupdb.py:147
+msgid "messages"
+msgstr ""
+
+#: ../roundup/roundupdb.py:147
+msgid "nosy"
+msgstr ""
+
+#: ../roundup/roundupdb.py:147
+msgid "superseder"
+msgstr ""
+
+#: ../roundup/roundupdb.py:147
+msgid "title"
+msgstr ""
+
+#: ../roundup/roundupdb.py:148
+msgid "assignedto"
+msgstr ""
+
+#: ../roundup/roundupdb.py:148
+msgid "keyword"
+msgstr ""
+
+#: ../roundup/roundupdb.py:148
+msgid "priority"
+msgstr ""
+
+#: ../roundup/roundupdb.py:148
+msgid "status"
+msgstr ""
+
+#: ../roundup/roundupdb.py:151
+msgid "activity"
+msgstr ""
+
+#. following properties are common for all hyperdb classes
+#. they are listed here to keep things in one place
+#: ../roundup/roundupdb.py:151
+msgid "actor"
+msgstr ""
+
+#: ../roundup/roundupdb.py:151
+msgid "creation"
+msgstr ""
+
+#: ../roundup/roundupdb.py:151
+msgid "creator"
+msgstr ""
+
+#: ../roundup/roundupdb.py:309
+#, python-format
+msgid "New submission from %(authname)s%(authaddr)s:"
+msgstr ""
+
+#: ../roundup/roundupdb.py:312
+#, python-format
+msgid "%(authname)s%(authaddr)s added the comment:"
+msgstr ""
+
+#: ../roundup/roundupdb.py:315
+#, python-format
+msgid "Change by %(authname)s%(authaddr)s:"
+msgstr ""
+
+#: ../roundup/roundupdb.py:342
+#, python-format
+msgid "File '%(filename)s' not attached - you can download it from %(link)s."
+msgstr ""
+
+#: ../roundup/roundupdb.py:615
+#, python-format
+msgid ""
+"\n"
+"Now:\n"
+"%(new)s\n"
+"Was:\n"
+"%(old)s"
+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 class] [[-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@server\n"
+" The username and password may be omitted:\n"
+"    pop username@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"
+"POPS:\n"
+" Connect to a POP server over ssl. This requires python 2.4 or later.\n"
+" This supports the same notation as POP.\n"
+"\n"
+"APOP:\n"
+" Same as POP, but using Authenticated POP:\n"
+"    apop username:password@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@server\n"
+" It also allows you to specify a specific mailbox other than INBOX using\n"
+" this format:\n"
+"    imap username:password@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@server [mailbox]\n"
+"\n"
+msgstr ""
+
+#: ../roundup/scripts/roundup_mailgw.py:151
+msgid "Error: not enough source specification information"
+msgstr ""
+
+#: ../roundup/scripts/roundup_mailgw.py:167
+msgid "Error: a later version of python is required"
+msgstr ""
+
+#: ../roundup/scripts/roundup_mailgw.py:170
+msgid "Error: pop specification not valid"
+msgstr ""
+
+#: ../roundup/scripts/roundup_mailgw.py:177
+msgid "Error: apop specification not valid"
+msgstr ""
+
+#: ../roundup/scripts/roundup_mailgw.py:189
+msgid ""
+"Error: The source must be either \"mailbox\", \"pop\", \"apop\", \"imap\" or "
+"\"imaps\""
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:76
+msgid "WARNING: generating temporary SSL certificate"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:253
+msgid ""
+"<html><head><title>Roundup trackers index</title></head>\n"
+"<body><h1>Roundup trackers index</h1><ol>\n"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:389
+#, python-format
+msgid "Error: %s: %s"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:399
+msgid "WARNING: ignoring \"-g\" argument, not root"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:405
+msgid "Can't change groups - no grp module"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:414
+#, python-format
+msgid "Group %(group)s doesn't exist"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:425
+msgid "Can't run as root!"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:428
+msgid "WARNING: ignoring \"-u\" argument, not root"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:434
+msgid "Can't change users - no pwd module"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:443
+#, python-format
+msgid "User %(user)s doesn't exist"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:592
+#, python-format
+msgid "Multiprocess mode \"%s\" is not available, switching to single-process"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:620
+#, python-format
+msgid "Unable to bind to port %s, port already in use."
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:688
+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:695
+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:702
+#, 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"
+" -i <fname>    set tracker index template\n"
+" -s            enable SSL\n"
+" -e <fname>    PEM file containing SSL key and certificate\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:860
+msgid "Instances must be name=home"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:874
+#, python-format
+msgid "Configuration saved to %s"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:892
+msgid "Sorry, you can't run the server as a daemon on this Operating System"
+msgstr ""
+
+#: ../roundup/scripts/roundup_server.py:907
+#, 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-empty.html:6
+msgid "Please specify your search parameters!"
+msgstr ""
+
+#: ../templates/classic/html/_generic.help-list.html:20
+#: ../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:35
+#: ../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:35
+#: ../templates/minimal/html/user.register.html:14
+msgid "You are not allowed to view this page."
+msgstr ""
+
+#: ../templates/classic/html/_generic.help-list.html:34
+msgid "1..25 out of 50"
+msgstr ""
+
+#: ../templates/classic/html/_generic.help-search.html:9
+msgid ""
+"Generic template ${template} or version for class ${classname} is not yet "
+"implemented"
+msgstr ""
+
+#: ../templates/classic/html/_generic.help-submit.html:57
+#: ../templates/classic/html/_generic.help.html:31
+#: ../templates/minimal/html/_generic.help.html:31
+msgid " Cancel "
+msgstr ""
+
+#: ../templates/classic/html/_generic.help-submit.html:63
+#: ../templates/classic/html/_generic.help.html:34
+#: ../templates/minimal/html/_generic.help.html:34
+msgid " Apply "
+msgstr ""
+
+#: ../templates/classic/html/_generic.help.html:9
+#: ../templates/classic/html/user.help.html:13
+#: ../templates/minimal/html/_generic.help.html:9
+msgid "${property} help - ${tracker}"
+msgstr ""
+
+#: ../templates/classic/html/_generic.help.html:41
+#: ../templates/classic/html/help.html:21
+#: ../templates/classic/html/issue.index.html:81
+#: ../templates/minimal/html/_generic.help.html:41
+msgid "&lt;&lt; previous"
+msgstr ""
+
+#: ../templates/classic/html/_generic.help.html:53
+#: ../templates/classic/html/help.html:28
+#: ../templates/classic/html/issue.index.html:89
+#: ../templates/minimal/html/_generic.help.html:53
+msgid "${start}..${end} out of ${total}"
+msgstr ""
+
+#: ../templates/classic/html/_generic.help.html:57
+#: ../templates/classic/html/help.html:32
+#: ../templates/classic/html/issue.index.html:92
+#: ../templates/minimal/html/_generic.help.html:57
+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:19
+#: ../templates/classic/html/_generic.item.html:16
+#: ../templates/classic/html/file.item.html:13
+#: ../templates/classic/html/issue.index.html:20
+#: ../templates/classic/html/issue.item.html:32
+#: ../templates/classic/html/msg.item.html:30
+#: ../templates/classic/html/user.index.html:13
+#: ../templates/classic/html/user.item.html:39
+#: ../templates/minimal/html/_generic.index.html:19
+#: ../templates/minimal/html/_generic.item.html:17
+#: ../templates/minimal/html/user.index.html:13
+#: ../templates/minimal/html/user.item.html:39
+#: ../templates/minimal/html/user.register.html:17
+msgid "Please login with your username and password."
+msgstr ""
+
+#: ../templates/classic/html/_generic.index.html:28
+#: ../templates/minimal/html/_generic.index.html:28
+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:50
+#: ../templates/minimal/html/_generic.index.html:50
+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:27
+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:48
+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:23
+#: ../templates/classic/html/user.register.html:17
+msgid "Name"
+msgstr ""
+
+#: ../templates/classic/html/file.item.html:45
+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:4
+#: ../templates/classic/html/issue.index.html:10
+msgid "List of issues"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:27
+#: ../templates/classic/html/issue.item.html:49
+msgid "Priority"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:28
+msgid "ID"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:29
+msgid "Creation"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:30
+msgid "Activity"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:31
+msgid "Actor"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:32
+#: ../templates/classic/html/keyword.item.html:37
+msgid "Keyword"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:33
+#: ../templates/classic/html/issue.item.html:44
+msgid "Title"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:34
+#: ../templates/classic/html/issue.item.html:51
+msgid "Status"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:35
+msgid "Creator"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:36
+msgid "Assigned&nbsp;To"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:105
+msgid "Download as CSV"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:115
+msgid "Sort on:"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:119
+#: ../templates/classic/html/issue.index.html:140
+msgid "- nothing -"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:127
+#: ../templates/classic/html/issue.index.html:148
+msgid "Descending:"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:136
+msgid "Group on:"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:155
+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:56
+msgid "Superseder"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:61
+msgid "View:"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:67
+msgid "Nosy List"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:76
+msgid "Assigned To"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:78
+#: ../templates/classic/html/page.html:103
+#: ../templates/minimal/html/page.html:102
+msgid "Keywords"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:86
+msgid "Change Note"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:94
+msgid "File"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:106
+msgid "Make a copy"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:114
+#: ../templates/classic/html/user.item.html:153
+#: ../templates/classic/html/user.register.html:69
+#: ../templates/minimal/html/user.item.html:153
+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:128
+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:132
+#: ../templates/classic/html/msg.item.html:61
+msgid "Files"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:134
+#: ../templates/classic/html/msg.item.html:63
+msgid "File name"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:135
+#: ../templates/classic/html/msg.item.html:64
+msgid "Uploaded"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:136
+msgid "Type"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:137
+#: ../templates/classic/html/query.edit.html:30
+msgid "Edit"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:138
+msgid "Remove"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:158
+#: ../templates/classic/html/issue.item.html:179
+#: ../templates/classic/html/query.edit.html:50
+msgid "remove"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:165
+#: ../templates/classic/html/msg.index.html:9
+msgid "Messages"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:169
+msgid "msg${id} (view)"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:170
+msgid "Author: ${author}"
+msgstr ""
+
+#: ../templates/classic/html/issue.item.html:172
+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:31
+msgid "Filter on"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:32
+msgid "Display"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:33
+msgid "Sort on"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:34
+msgid "Group on"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:38
+msgid "All text*:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:46
+msgid "Title:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:56
+msgid "Keyword:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:58
+#: ../templates/classic/html/issue.search.html:123
+#: ../templates/classic/html/issue.search.html:139
+msgid "not selected"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:67
+msgid "ID:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:75
+msgid "Creation Date:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:86
+msgid "Creator:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:88
+msgid "created by me"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:97
+msgid "Activity:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:108
+msgid "Actor:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:110
+msgid "done by me"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:121
+msgid "Priority:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:134
+msgid "Status:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:137
+msgid "not resolved"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:152
+msgid "Assigned to:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:155
+msgid "assigned to me"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:157
+msgid "unassigned"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:167
+msgid "No Sort or group:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:175
+msgid "Pagesize:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:181
+msgid "Start With:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:187
+msgid "Sort Descending:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:194
+msgid "Group Descending:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:201
+msgid "Query name**:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:213
+#: ../templates/classic/html/page.html:43
+#: ../templates/classic/html/page.html:92
+#: ../templates/classic/html/user.help-search.html:69
+#: ../templates/minimal/html/page.html:43
+#: ../templates/minimal/html/page.html:91
+msgid "Search"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:218
+msgid "*: The \"all text\" field will look in message bodies and issue titles"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:221
+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/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:38
+msgid "Author"
+msgstr ""
+
+#: ../templates/classic/html/msg.item.html:43
+msgid "Recipients"
+msgstr ""
+
+#: ../templates/classic/html/msg.item.html:54
+msgid "Content"
+msgstr ""
+
+#: ../templates/classic/html/page.html:54
+#: ../templates/minimal/html/page.html:53
+msgid "<b>Your Queries</b> (<a href=\"query?@template=edit\">edit</a>)"
+msgstr ""
+
+#: ../templates/classic/html/page.html:65
+#: ../templates/minimal/html/page.html:64
+msgid "Issues"
+msgstr ""
+
+#: ../templates/classic/html/page.html:67
+#: ../templates/classic/html/page.html:105
+#: ../templates/minimal/html/page.html:66
+#: ../templates/minimal/html/page.html:104
+msgid "Create New"
+msgstr ""
+
+#: ../templates/classic/html/page.html:69
+#: ../templates/minimal/html/page.html:68
+msgid "Show Unassigned"
+msgstr ""
+
+#: ../templates/classic/html/page.html:81
+#: ../templates/minimal/html/page.html:80
+msgid "Show All"
+msgstr ""
+
+#: ../templates/classic/html/page.html:93
+#: ../templates/minimal/html/page.html:92
+msgid "Show issue:"
+msgstr ""
+
+#: ../templates/classic/html/page.html:108
+#: ../templates/minimal/html/page.html:107
+msgid "Edit Existing"
+msgstr ""
+
+#: ../templates/classic/html/page.html:114
+#: ../templates/minimal/html/page.html:113
+msgid "Administration"
+msgstr ""
+
+#: ../templates/classic/html/page.html:116
+#: ../templates/minimal/html/page.html:115
+msgid "Class List"
+msgstr ""
+
+#: ../templates/classic/html/page.html:120
+#: ../templates/minimal/html/page.html:119
+msgid "User List"
+msgstr ""
+
+#: ../templates/classic/html/page.html:122
+#: ../templates/minimal/html/page.html:121
+msgid "Add User"
+msgstr ""
+
+#: ../templates/classic/html/page.html:129
+#: ../templates/classic/html/page.html:135
+#: ../templates/minimal/html/page.html:128
+#: ../templates/minimal/html/page.html:134
+msgid "Login"
+msgstr ""
+
+#: ../templates/classic/html/page.html:134
+#: ../templates/minimal/html/page.html:133
+msgid "Remember me?"
+msgstr ""
+
+#: ../templates/classic/html/page.html:138
+#: ../templates/classic/html/user.register.html:63
+#: ../templates/minimal/html/page.html:137
+#: ../templates/minimal/html/user.register.html:61
+msgid "Register"
+msgstr ""
+
+#: ../templates/classic/html/page.html:141
+#: ../templates/minimal/html/page.html:140
+msgid "Lost&nbsp;your&nbsp;login?"
+msgstr ""
+
+#: ../templates/classic/html/page.html:146
+#: ../templates/minimal/html/page.html:145
+msgid "Hello, ${user}"
+msgstr ""
+
+#: ../templates/classic/html/page.html:148
+msgid "Your Issues"
+msgstr ""
+
+#: ../templates/classic/html/page.html:160
+#: ../templates/minimal/html/page.html:147
+msgid "Your Details"
+msgstr ""
+
+#: ../templates/classic/html/page.html:162
+#: ../templates/minimal/html/page.html:149
+msgid "Logout"
+msgstr ""
+
+#: ../templates/classic/html/page.html:166
+#: ../templates/minimal/html/page.html:153
+msgid "Help"
+msgstr ""
+
+#: ../templates/classic/html/page.html:167
+#: ../templates/minimal/html/page.html:154
+msgid "Roundup docs"
+msgstr ""
+
+#: ../templates/classic/html/page.html:177
+#: ../templates/minimal/html/page.html:164
+msgid "clear this message"
+msgstr ""
+
+#: ../templates/classic/html/page.html:241
+#: ../templates/classic/html/page.html:256
+#: ../templates/classic/html/page.html:270
+#: ../templates/minimal/html/page.html:228
+#: ../templates/minimal/html/page.html:243
+#: ../templates/minimal/html/page.html:257
+msgid "don't care"
+msgstr ""
+
+#: ../templates/classic/html/page.html:243
+#: ../templates/classic/html/page.html:258
+#: ../templates/classic/html/page.html:271
+#: ../templates/minimal/html/page.html:230
+#: ../templates/minimal/html/page.html:245
+#: ../templates/minimal/html/page.html:258
+msgid "------------"
+msgstr ""
+
+#: ../templates/classic/html/page.html:299
+#: ../templates/minimal/html/page.html:286
+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:94
+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:96
+msgid "[not yours to edit]"
+msgstr ""
+
+#: ../templates/classic/html/query.edit.html:104
+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.help-search.html:73
+msgid "Pagesize"
+msgstr ""
+
+#: ../templates/classic/html/user.help.html:43
+msgid ""
+"Your browser is not capable of using frames; you should be redirected "
+"immediately, or visit ${link}."
+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:19
+#: ../templates/minimal/html/user.index.html:19
+msgid "Username"
+msgstr ""
+
+#: ../templates/classic/html/user.index.html:20
+msgid "Real name"
+msgstr ""
+
+#: ../templates/classic/html/user.index.html:21
+#: ../templates/classic/html/user.register.html:45
+msgid "Organisation"
+msgstr ""
+
+#: ../templates/classic/html/user.index.html:22
+#: ../templates/minimal/html/user.index.html:20
+msgid "Email address"
+msgstr ""
+
+#: ../templates/classic/html/user.index.html:23
+msgid "Phone number"
+msgstr ""
+
+#: ../templates/classic/html/user.index.html:24
+msgid "Retire"
+msgstr ""
+
+#: ../templates/classic/html/user.index.html:37
+msgid "retire"
+msgstr ""
+
+#: ../templates/classic/html/user.item.html:9
+#: ../templates/minimal/html/user.item.html:9
+msgid "User ${id}: ${title} - ${tracker}"
+msgstr ""
+
+#: ../templates/classic/html/user.item.html:12
+#: ../templates/minimal/html/user.item.html:12
+msgid "New User - ${tracker}"
+msgstr ""
+
+#: ../templates/classic/html/user.item.html:21
+#: ../templates/minimal/html/user.item.html:21
+msgid "New User"
+msgstr ""
+
+#: ../templates/classic/html/user.item.html:23
+#: ../templates/minimal/html/user.item.html:23
+msgid "New User Editing"
+msgstr ""
+
+#: ../templates/classic/html/user.item.html:26
+#: ../templates/minimal/html/user.item.html:26
+msgid "User${id}"
+msgstr ""
+
+#: ../templates/classic/html/user.item.html:29
+#: ../templates/minimal/html/user.item.html:29
+msgid "User${id} Editing"
+msgstr ""
+
+#: ../templates/classic/html/user.item.html:80
+#: ../templates/classic/html/user.register.html:33
+#: ../templates/minimal/html/user.item.html:80
+#: ../templates/minimal/html/user.register.html:41
+msgid "Roles"
+msgstr ""
+
+#: ../templates/classic/html/user.item.html:88
+#: ../templates/minimal/html/user.item.html:88
+msgid "(to give the user more than one role, enter a comma,separated,list)"
+msgstr ""
+
+#: ../templates/classic/html/user.item.html:109
+#: ../templates/minimal/html/user.item.html:109
+msgid "(this is a numeric hour offset, the default is ${zone})"
+msgstr ""
+
+#: ../templates/classic/html/user.item.html:130
+#: ../templates/classic/html/user.register.html:53
+#: ../templates/minimal/html/user.item.html:130
+#: ../templates/minimal/html/user.register.html:53
+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.register.html:21
+#: ../templates/minimal/html/user.register.html:29
+msgid "Login Name"
+msgstr ""
+
+#: ../templates/classic/html/user.register.html:25
+#: ../templates/minimal/html/user.register.html:33
+msgid "Login Password"
+msgstr ""
+
+#: ../templates/classic/html/user.register.html:29
+#: ../templates/minimal/html/user.register.html:37
+msgid "Confirm Password"
+msgstr ""
+
+#: ../templates/classic/html/user.register.html:41
+msgid "Phone"
+msgstr ""
+
+#: ../templates/classic/html/user.register.html:49
+#: ../templates/minimal/html/user.register.html:49
+msgid "E-mail address"
+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/classic/initial_data.py:5
+msgid "critical"
+msgstr ""
+
+#: ../templates/classic/initial_data.py:6
+msgid "urgent"
+msgstr ""
+
+#: ../templates/classic/initial_data.py:7
+msgid "bug"
+msgstr ""
+
+#: ../templates/classic/initial_data.py:8
+msgid "feature"
+msgstr ""
+
+#: ../templates/classic/initial_data.py:9
+msgid "wish"
+msgstr ""
+
+#: ../templates/classic/initial_data.py:12
+msgid "unread"
+msgstr ""
+
+#: ../templates/classic/initial_data.py:13
+msgid "deferred"
+msgstr ""
+
+#: ../templates/classic/initial_data.py:14
+msgid "chatting"
+msgstr ""
+
+#: ../templates/classic/initial_data.py:15
+msgid "need-eg"
+msgstr ""
+
+#: ../templates/classic/initial_data.py:16
+msgid "in-progress"
+msgstr ""
+
+#: ../templates/classic/initial_data.py:17
+msgid "testing"
+msgstr ""
+
+#: ../templates/classic/initial_data.py:18
+msgid "done-cbb"
+msgstr ""
+
+#: ../templates/classic/initial_data.py:19
+msgid "resolved"
+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 ""
diff --git a/locale/ru.po b/locale/ru.po
new file mode 100644 (file)
index 0000000..87fb3f0
--- /dev/null
@@ -0,0 +1,3515 @@
+# Russian message file for Roundup Issue Tracker
+# alexander smishlajev <alex@tycobka.lv>, 2004
+#
+# $Id: ru.po,v 1.16 2007-09-16 07:23:04 a1s Exp $
+#
+# roundup.pot revision 1.23
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Roundup 1.3.2\n"
+"Report-Msgid-Bugs-To: roundup-devel@lists.sourceforge.net\n"
+"POT-Creation-Date: 2006-04-27 09:02+0300\n"
+"PO-Revision-Date: 2007-09-16 10:20+0200\n"
+"Last-Translator: alexander smishlajev <alex@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"
+"X-Poedit-Language: Russian\n"
+
+#: ../roundup/admin.py:86
+#: ../roundup/admin.py:989
+#: ../roundup/admin.py:1040
+#: ../roundup/admin.py:1063
+#, python-format
+msgid "no such class \"%(classname)s\""
+msgstr "ëÌÁÓÓ \"%(classname)s\" ÎÅ ÓÕÝÅÓÔ×ÕÅÔ"
+
+#: ../roundup/admin.py:96
+#: ../roundup/admin.py:100
+#, python-format
+msgid "argument \"%(arg)s\" not propname=value"
+msgstr "ÁÒÇÕÍÅÎÔ \"%(arg)s\" ÄÏÌÖÅΠÉÍÅÔØ ×ÉÄ ÉÍÑ=ÚÎÁÞÅÎÉÅ"
+
+#: ../roundup/admin.py:113
+#, python-format
+msgid ""
+"Problem: %(message)s\n"
+"\n"
+msgstr ""
+"ïÛÉÂËÁ: %(message)s\n"
+"\n"
+
+#: ../roundup/admin.py:114
+#, 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"
+" -V                -- be verbose when importing\n"
+" -v                -- report Roundup and Python versions (and quit)\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"
+" -V            -- ×ÙÄÁ×ÁÔØ ÄÏÐÏÌÎÉÔÅÌØÎÙÅ ÓÏÏÂÝÅÎÉÑ ÐÒÉ ÉÍÐÏÒÔÅ ÄÁÎÎÙÈ.\n"
+" -v            -- ÐÏËÁÚÁÔØ ×ÅÒÓÉÉ Roundup É Python (É ÚÁ×ÅÒÛÉÔØ ÒÁÂÏÔÕ).\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:141
+msgid "Commands:"
+msgstr "ëÏÍÁÎÄÙ:"
+
+#: ../roundup/admin.py:148
+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:178
+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:241
+#, python-format
+msgid "%s:"
+msgstr ""
+
+#: ../roundup/admin.py:246
+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:269
+#, python-format
+msgid "Sorry, no help for \"%(topic)s\""
+msgstr "é×ÉÎÉÔÅ, ÓÐÒÁ×ËÁ \"%(topic)s\" ÎÅ ÓÕÝÅÓÔ×ÕÅÔ."
+
+#: ../roundup/admin.py:346
+#: ../roundup/admin.py:402
+msgid "Templates:"
+msgstr "ûÁÂÌÏÎÙ:"
+
+#: ../roundup/admin.py:349
+#: ../roundup/admin.py:413
+msgid "Back ends:"
+msgstr "óÅÒ×ÅÒÙ:"
+
+#: ../roundup/admin.py:352
+msgid ""
+"Usage: install [template [backend [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 and backend may be specified on the command-line\n"
+"        as arguments, in that order.\n"
+"\n"
+"        Command line arguments following the backend allows you to\n"
+"        pass initial values 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:375
+#: ../roundup/admin.py:472
+#: ../roundup/admin.py:533
+#: ../roundup/admin.py:612
+#: ../roundup/admin.py:663
+#: ../roundup/admin.py:721
+#: ../roundup/admin.py:742
+#: ../roundup/admin.py:770
+#: ../roundup/admin.py:842
+#: ../roundup/admin.py:909
+#: ../roundup/admin.py:980
+#: ../roundup/admin.py:1030
+#: ../roundup/admin.py:1053
+#: ../roundup/admin.py:1084
+#: ../roundup/admin.py:1180
+#: ../roundup/admin.py:1253
+msgid "Not enough arguments supplied"
+msgstr "îÅÄÏÓÔÁÔÏÞÎÏ ÁÒÇÕÍÅÎÔÏ×"
+
+#: ../roundup/admin.py:381
+#, python-format
+msgid "Instance home parent directory \"%(parent)s\" does not exist"
+msgstr "ëÁÔÁÌÏÇ \"%(parent)s\" ÎÅ ÓÕÝÅÓÔ×ÕÅÔ"
+
+#: ../roundup/admin.py:389
+#, 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:404
+msgid "Select template [classic]: "
+msgstr "÷ÙÂÅÒÉÔÅ ÛÁÂÌÏΠ[classic]: "
+
+#: ../roundup/admin.py:415
+msgid "Select backend [anydbm]: "
+msgstr "÷ÙÂÅÒÉÔÅ ÓÅÒ×ÅÒ [anydbm]: "
+
+#: ../roundup/admin.py:425
+#, python-format
+msgid "Error in configuration settings: \"%s\""
+msgstr "ïÛÉÂËÁ × ÐÁÒÁÍÅÔÒÁÈ ËÏÎÆÉÇÕÒÁÃÉÉ: \"%s\""
+
+#: ../roundup/admin.py:434
+#, 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:444
+msgid " ... at a minimum, you must set following options:"
+msgstr " ... ËÁË ÍÉÎÉÍÕÍ, ×Ù ÄÏÌÖÎÙ ÕÓÔÁÎÏ×ÉÔØ ÎÁÓÔÒÏÊËÉ:"
+
+# õËÁÚÁÎÏ ÁÎÇÌÉÊÓËÏÅ ÎÁÚ×ÁÎÉÅ ÄÏËÕÍÅÎÔÁ
+#: ../roundup/admin.py:449
+#, 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:467
+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:477
+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:491
+msgid "Admin Password: "
+msgstr "ðÁÒÏÌØ ÁÄÍÉÎÉÓÔÒÁÔÏÒÁ: "
+
+#: ../roundup/admin.py:492
+msgid "       Confirm: "
+msgstr "              åÝÅ ÒÁÚ: "
+
+#: ../roundup/admin.py:496
+msgid "Instance home does not exist"
+msgstr "äÏÍÁÛÎÉÊ ËÁÔÁÌÏÇ ÔÒÅËÅÒÁ ÎÅ ÓÕÝÅÓÔ×ÕÅÔ"
+
+#: ../roundup/admin.py:500
+msgid "Instance has not been installed"
+msgstr "ôÒÅËÅÒ ÎÅ ÕÓÔÁÎÏ×ÌÅÎ"
+
+#: ../roundup/admin.py:505
+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:526
+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:566
+#: ../roundup/admin.py:581
+#, 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:589
+#: ../roundup/admin.py:991
+#: ../roundup/admin.py:1042
+#: ../roundup/admin.py:1065
+#, python-format
+msgid "no such %(classname)s node \"%(nodeid)s\""
+msgstr "÷ ËÌÁÓÓÅ %(classname)s ÎÅÔ ÏÂßÅËÔÁ \"%(nodeid)s\""
+
+#: ../roundup/admin.py:591
+#, python-format
+msgid "no such %(classname)s property \"%(propname)s\""
+msgstr "õ ËÌÁÓÓÁ %(classname)s ÎÅÔ ÁÔÒÉÂÕÔÁ \"%(propname)s\""
+
+#: ../roundup/admin.py:600
+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:655
+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:708
+#: ../roundup/admin.py:862
+#: ../roundup/admin.py:874
+#: ../roundup/admin.py:928
+#, python-format
+msgid "%(classname)s has no property \"%(propname)s\""
+msgstr "ëÌÁÓÓ %(classname)s ÎÅ ÉÍÅÅÔ ÁÔÒÉÂÕÔÁ \"%(propname)s\""
+
+#: ../roundup/admin.py:715
+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:730
+#, python-format
+msgid "%(key)s: %(value)s (key property)"
+msgstr "%(key)s: %(value)s (ËÌÀÞÅ×ÏÊ ÁÔÒÉÂÕÔ)"
+
+#: ../roundup/admin.py:732
+#, python-format
+msgid "%(key)s: %(value)s"
+msgstr ""
+
+#: ../roundup/admin.py:735
+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:759
+#, python-format
+msgid "%(key)s: %(value)r"
+msgstr ""
+
+#: ../roundup/admin.py:762
+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:789
+#, python-format
+msgid "%(propname)s (Password): "
+msgstr " %(propname)s (ÐÁÒÏÌØ): "
+
+#: ../roundup/admin.py:791
+#, python-format
+msgid "   %(propname)s (Again): "
+msgstr "%(propname)s (ÅÝÅ ÒÁÚ): "
+
+#: ../roundup/admin.py:793
+msgid "Sorry, try again..."
+msgstr "ðÁÒÏÌÉ ÎÅ ÓÏ×ÐÁÌÉ.  ðÏÐÒÏÂÕÊÔÅ ÅÝÅ ÒÁÚ."
+
+#: ../roundup/admin.py:797
+#, python-format
+msgid "%(propname)s (%(proptype)s): "
+msgstr ""
+
+#: ../roundup/admin.py:815
+#, python-format
+msgid "you must provide the \"%(propname)s\" property."
+msgstr "áÔÒÉÂÕÔ \"%(propname)s\" ÄÏÌÖÅΠÂÙÔØ ÚÁÐÏÌÎÅÎ."
+
+#: ../roundup/admin.py:827
+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:840
+msgid "Too many arguments supplied"
+msgstr "ðÏÄÁÎÏ ÓÌÉÛËÏÍ ÍÎÏÇÏ ÐÁÒÁÍÅÔÒÏ×"
+
+#: ../roundup/admin.py:876
+#, python-format
+msgid "%(nodeid)4s: %(value)s"
+msgstr ""
+
+#: ../roundup/admin.py:880
+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:924
+#, python-format
+msgid "\"%(spec)s\" not name:width"
+msgstr "úÎÁÞÅÎÉÅ \"%(spec)s\" ÄÏÌÖÎÏ ÂÙÔØ ÚÁÄÁÎÏ ËÁË ÉÍÑ:ÛÉÒÉÎÁ"
+
+#: ../roundup/admin.py:974
+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:995
+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:1010
+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:1023
+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:1047
+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:1070
+msgid ""
+"Usage: export [[-]class[,class]] export_dir\n"
+"        Export the database to colon-separated-value files.\n"
+"        To exclude the files (e.g. for the msg or file class),\n"
+"        use the exporttables command.\n"
+"\n"
+"        Optionally limit the export to just the named classes\n"
+"        or exclude the named classes, if the 1st argument starts with '-'.\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"
+"        ÓÔÒÏËÁ ÜËÓÐÏÒÔÎÏÇÏ ÆÁÊÌÁ.  úÎÁÞÅÎÉÑ ÁÔÒÉÂÕÔÏ× ÒÁÚÄÅÌÑÀÔÓÑ\n"
+"        Ä×ÏÅÔÏÞÉÑÍÉ.\n"
+"\n"
+"        ëÏÍÁÎÄÁ export ËÏÐÉÒÕÅÔ × ËÁÔÁÌÏÇ ÜËÓÐÏÒÔÁ ÆÁÊÌÙ ÄÁÎÎÙÈ,\n"
+"        ÒÁÓÐÏÌÏÖÅÎÎÙÅ × $TRACKER_HOME/db/files/.  äÌÑ ÜËÓÐÏÒÔÁ ÔÏÌØËÏ\n"
+"        ÔÁÂÌÉàÂÁÚÙ ÄÁÎÎÙÈ ÂÅÚ ÄÏÐÏÌÎÉÔÅÌØÎÙÈ ÆÁÊÌÏ× ÉÓÐÏÌØÚÕÊÔÅ ËÏÍÁÎÄÕ\n"
+"        exporttables.\n"
+"        "
+
+#: ../roundup/admin.py:1145
+msgid ""
+"Usage: exporttables [[-]class[,class]] export_dir\n"
+"        Export the database to colon-separated-value files, excluding the\n"
+"        files below $TRACKER_HOME/db/files/ (which can be archived separately).\n"
+"        To include the files, use the export command.\n"
+"\n"
+"        Optionally limit the export to just the named classes\n"
+"        or exclude the named classes, if the 1st argument starts with '-'.\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 ""
+"÷ÙÚÏ×: exporttables [[-]ËÌÁÓÓ[,ËÌÁÓÓ]] ËÁÔÁÌÏÇ\n"
+"        üËÓÐÏÒÔÉÒÏ×ÁÔØ ÓÏÄÅÒÖÉÍÏÅ ÂÁÚÙ ÄÁÎÎÙÈ × ÔÅËÓÔÏ×ÙÅ ÆÁÊÌÙ.\n"
+"\n"
+"        ðÅÒ×ÙÊ (ÎÅÏÂÑÚÁÔÅÌØÎÙÊ) ÐÁÒÁÍÅÔÒ ÜÔÏÊ ËÏÍÁÎÄÙ ÚÁÄÁÅÔ ÓÐÉÓÏË ËÌÁÓÓÏ×,\n"
+"        ËÏÔÏÒÙÅ ÎÕÖÎÏ ÜËÓÐÏÒÔÉÒÏ×ÁÔØ, ÉÌÉ, ÅÓÌÉ ÏΠÎÁÞÉÎÁÅÔÓÑ ÓÏ ÚÎÁËÁ\n"
+"        \"ÍÉÎÕÓ\", - ÓÐÉÓÏË ËÌÁÓÓÏ×, ËÏÔÏÒÙÅ ÎÕÖÎÏ ÉÓËÌÀÞÉÔØ ÉÚ ÜËÓÐÏÒÔÁ.\n"
+"        åÓÌÉ ÓÐÉÓÏË ËÌÁÓÓÏ× ÎÅ ÚÁÄÁÎ, ÜËÓÐÏÒÔÉÒÕÀÔÓÑ ×ÓÅ ËÌÁÓÓÙ ÂÁÚÙ ÄÁÎÎÙÈ.\n"
+"\n"
+"        üÔÁ ËÏÍÁÎÄÁ ÜËÓÐÏÒÔÉÒÕÅÔ ÄÁÎÎÙÅ ÉÚ ÂÁÚÙ ÔÒÅËÅÒÁ × ÔÅËÓÔÏ×ÙÅ ÆÁÊÌÙ\n"
+"        × ÕËÁÚÁÎÎÏÍ ËÁÔÁÌÏÇÅ.  äÌÑ ËÁÖÄÏÇÏ ÜËÓÐÏÒÔÉÒÕÅÍÏÇÏ ËÌÁÓÓÁ ÓÏÚÄÁÅÔÓÑ\n"
+"        ÏÔÄÅÌØÎÙÊ ÜËÓÐÏÒÔÎÙÊ ÆÁÊÌ.  äÌÑ ËÁÖÄÏÇÏ ÏÂßÅËÔÁ ËÌÁÓÓÁ ÓÏÚÄÁÅÔÓÑ\n"
+"        ÓÔÒÏËÁ ÜËÓÐÏÒÔÎÏÇÏ ÆÁÊÌÁ.  úÎÁÞÅÎÉÑ ÁÔÒÉÂÕÔÏ× ÒÁÚÄÅÌÑÀÔÓÑ\n"
+"        Ä×ÏÅÔÏÞÉÑÍÉ.\n"
+"\n"
+"        ëÏÍÁÎÄÁ exporttables ÎÅ ËÏÐÉÒÕÅÔ × ËÁÔÁÌÏÇ ÜËÓÐÏÒÔÁ ÆÁÊÌÙ ÄÁÎÎÙÈ,\n"
+"        ÒÁÓÐÏÌÏÖÅÎÎÙÅ × $TRACKER_HOME/db/files/ (ÜÔÉ ÆÁÊÌÙ ÍÏÖÎÏ ÐÏÔÏÍ\n"
+"        ÓËÏÐÉÒÏ×ÁÔØ ÏÔÄÅÌØÎÏ).  äÌÑ ÔÏÇÏ ÞÔÏÂÙ ÜËÓÐÏÒÔÉÒÏ×ÁÔØ ÂÁÚÕ ÄÁÎÎÙÈ\n"
+"        ÐÏÌÎÏÓÔØÀ, ÉÓÐÏÌØÚÕÊÔÅ ËÏÍÁÎÄÕ export.\n"
+"        "
+
+#: ../roundup/admin.py:1160
+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:1235
+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:1263
+msgid "Invalid format"
+msgstr "îÅÐÒÁ×ÉÌØÎÙÊ ÆÏÒÍÁÔ"
+
+#: ../roundup/admin.py:1274
+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:1288
+#, python-format
+msgid "no such item \"%(designator)s\""
+msgstr "ÏÂßÅËÔ \"%(designator)s\" ÎÅ ÓÕÝÅÓÔ×ÕÅÔ"
+
+#: ../roundup/admin.py:1298
+msgid ""
+"Usage: security [Role name]\n"
+"        Display the Permissions available to one or all Roles.\n"
+"        "
+msgstr ""
+"÷ÙÚÏ×: security [ÒÏÌØ]\n"
+"        ðÏËÁÚÁÔØ ÐÒÁ×Á, ×ÙÄÁÎÎÙÅ ÕËÁÚÁÎÎÏÊ ÒÏÌÉ ÉÌÉ ×ÓÅÍ ÓÕÝÅÓÔ×ÕÀÝÉÍ\n"
+"        ÒÏÌÑÍ.\n"
+"        "
+
+#: ../roundup/admin.py:1306
+#, python-format
+msgid "No such Role \"%(role)s\""
+msgstr "òÏÌØ \"%(role)s\" ÎÅ ÓÕÝÅÓÔ×ÕÅÔ"
+
+#: ../roundup/admin.py:1312
+#, python-format
+msgid "New Web users get the Roles \"%(role)s\""
+msgstr "îÏ×ÙÅ ÐÏÌØÚÏ×ÁÔÅÌÉ web ÐÏÌÕÞÁÀÔ ÒÏÌÉ \"%(role)s\""
+
+#: ../roundup/admin.py:1314
+#, python-format
+msgid "New Web users get the Role \"%(role)s\""
+msgstr "îÏ×ÙÅ ÐÏÌØÚÏ×ÁÔÅÌÉ web ÐÏÌÕÞÁÀÔ ÒÏÌØ \"%(role)s\""
+
+#: ../roundup/admin.py:1317
+#, python-format
+msgid "New Email users get the Roles \"%(role)s\""
+msgstr "îÏ×ÙÅ ÐÏÌØÚÏ×ÁÔÅÌÉ email ÐÏÌÕÞÁÀÔ ÒÏÌÉ \"%(role)s\""
+
+#: ../roundup/admin.py:1319
+#, python-format
+msgid "New Email users get the Role \"%(role)s\""
+msgstr "îÏ×ÙÅ ÐÏÌØÚÏ×ÁÔÅÌÉ email ÐÏÌÕÞÁÀÔ ÒÏÌØ \"%(role)s\""
+
+#: ../roundup/admin.py:1322
+#, python-format
+msgid "Role \"%(name)s\":"
+msgstr "òÏÌØ \"%(name)s\":"
+
+#: ../roundup/admin.py:1327
+#, 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:1330
+#, python-format
+msgid " %(description)s (%(name)s for \"%(klass)s\" only)"
+msgstr " %(description)s (%(name)s ÔÏÌØËÏ ÄÌÑ ËÌÁÓÓÁ \"%(klass)s\")"
+
+#: ../roundup/admin.py:1333
+#, python-format
+msgid " %(description)s (%(name)s)"
+msgstr ""
+
+#: ../roundup/admin.py:1362
+#, python-format
+msgid "Unknown command \"%(command)s\" (\"help commands\" for a list)"
+msgstr "ëÏÍÁÎÄÁ \"%(command)s\" ÎÅÉÚ×ÅÓÔÎÁ. (\"help commands\" ×ÙÄÁÅÔ ÓÐÉÓÏË ËÏÍÁÎÄ)"
+
+#: ../roundup/admin.py:1368
+#, python-format
+msgid "Multiple commands match \"%(command)s\": %(list)s"
+msgstr "\"%(command)s\" ÓÏÏÔ×ÅÔÓÔ×ÕÅÔ ÎÅÓËÏÌØËÉÍ ËÏÍÁÎÄÁÍ: %(list)s"
+
+#: ../roundup/admin.py:1375
+msgid "Enter tracker home: "
+msgstr "äÏÍÁÛÎÉÊ ËÁÔÁÌÏÇ ÔÒÅËÅÒÁ: "
+
+#: ../roundup/admin.py:1382
+#: ../roundup/admin.py:1388
+#: ../roundup/admin.py:1408
+#, python-format
+msgid "Error: %(message)s"
+msgstr "ïÛÉÂËÁ: %(message)s"
+
+#: ../roundup/admin.py:1396
+#, python-format
+msgid "Error: Couldn't open tracker: %(message)s"
+msgstr "ïÛÉÂËÁ: ôÒÅËÅÒ ÎÅ ÏÔËÒÙ×ÁÅÔÓÑ: %(message)s"
+
+#: ../roundup/admin.py:1421
+#, python-format
+msgid ""
+"Roundup %s ready for input.\n"
+"Type \"help\" for help."
+msgstr ""
+"Roundup %s Ë ×ÁÛÉÍ ÕÓÌÕÇÁÍ.\n"
+"÷×ÅÄÉÔÅ \"help\" ÄÌÑ ÓÐÒÁ×ËÉ."
+
+#: ../roundup/admin.py:1426
+msgid "Note: command history and editing not available"
+msgstr "ðÒÉÍÅÞÁÎÉÅ: ÒÁÂÏÔÁÅÔ ÒÅÄÁËÔÏÒ É ÉÓÔÏÒÉÑ ËÏÍÁÎÄ"
+
+#: ../roundup/admin.py:1430
+msgid "roundup> "
+msgstr ""
+
+#: ../roundup/admin.py:1432
+msgid "exit..."
+msgstr "ÐÒÉÈÏÄÉÔÅ Ë ÎÁÍ ÅÝÅ..."
+
+#: ../roundup/admin.py:1442
+msgid "There are unsaved changes. Commit them (y/N)? "
+msgstr "ïÊ, ÔÕÔ ÎÅÓÏÈÒÁÎÅÎÎÙÅ ÉÚÍÅÎÅÎÉÑ. úÁÐÉÓÁÔØ × ÂÁÚÕ ÄÁÎÎÙÈ (y/N)? "
+
+#: ../roundup/backends/back_anydbm.py:2004
+#, python-format
+msgid "WARNING: invalid date tuple %r"
+msgstr "÷îéíáîéå! îÅ×ÅÒÎÁÑ ÄÁÔÁ: %r"
+
+#: ../roundup/backends/rdbms_common.py:1445
+msgid "create"
+msgstr "ÓÏÚÄÁÎÉÅ"
+
+#: ../roundup/backends/rdbms_common.py:1611
+msgid "unlink"
+msgstr "ÏÔ×ÑÚËÁ"
+
+#: ../roundup/backends/rdbms_common.py:1615
+msgid "link"
+msgstr "ÐÒÉ×ÑÚËÁ"
+
+#: ../roundup/backends/rdbms_common.py:1737
+msgid "set"
+msgstr "ÕÓÔÁÎÏ×ËÁ"
+
+#: ../roundup/backends/rdbms_common.py:1761
+msgid "retired"
+msgstr "ÚÁÐÒÅÝÅÎÉÅ"
+
+#: ../roundup/backends/rdbms_common.py:1791
+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:169
+#: ../roundup/cgi/actions.py:197
+msgid "You do not have permission to edit queries"
+msgstr "õ ×ÁÓ ÎÅÔ ÒÁÚÒÅÛÅÎÉÑ ÎÁ ÒÅÄÁËÔÉÒÏ×ÁÎÉÅ ÚÁÐÒÏÓÏ×"
+
+#: ../roundup/cgi/actions.py:175
+#: ../roundup/cgi/actions.py:204
+msgid "You do not have permission to store queries"
+msgstr "õ ×ÁÓ ÎÅÔ ÒÁÚÒÅÛÅÎÉÑ ÎÁ ÓÏÈÒÁÎÅÎÉÅ ÚÁÐÒÏÓÏ×"
+
+#: ../roundup/cgi/actions.py:310
+#, python-format
+msgid "Not enough values on line %(line)s"
+msgstr "÷ ÓÔÒÏËÅ %(line)s ÎÅ È×ÁÔÁÅÔ ÚÎÁÞÅÎÉÊ"
+
+#: ../roundup/cgi/actions.py:357
+msgid "Items edited OK"
+msgstr "ïÂßÅËÔÙ ÉÚÍÅÎÅÎÙ ÕÓÐÅÛÎÏ"
+
+#: ../roundup/cgi/actions.py:416
+#, python-format
+msgid "%(class)s %(id)s %(properties)s edited ok"
+msgstr "éÚÍÅÎÅÎÙ ÁÔÒÉÂÕÔÙ %(properties)s ÏÂßÅËÔÁ %(class)s %(id)s"
+
+#: ../roundup/cgi/actions.py:419
+#, python-format
+msgid "%(class)s %(id)s - nothing changed"
+msgstr "%(class)s %(id)s - ÎÅÔ ÉÚÍÅÎÅÎÉÊ"
+
+#: ../roundup/cgi/actions.py:431
+#, python-format
+msgid "%(class)s %(id)s created"
+msgstr "%(class)s %(id)s ÓÏÚÄÁÎ"
+
+#: ../roundup/cgi/actions.py:463
+#, python-format
+msgid "You do not have permission to edit %(class)s"
+msgstr "õ ×ÁÓ ÎÅÔ ÒÁÚÒÅÛÅÎÉÑ ÒÅÄÁËÔÉÒÏ×ÁÔØ %(class)s"
+
+#: ../roundup/cgi/actions.py:475
+#, python-format
+msgid "You do not have permission to create %(class)s"
+msgstr "õ ×ÁÓ ÎÅÔ ÒÁÚÒÅÛÅÎÉÑ ÓÏÚÄÁ×ÁÔØ %(class)s"
+
+#: ../roundup/cgi/actions.py:499
+msgid "You do not have permission to edit user roles"
+msgstr "õ ×ÁÓ ÎÅÔ ÒÁÚÒÅÛÅÎÉÑ ÎÁ ÉÚÍÅÎÅÎÉÅ ÒÏÌÅÊ ÐÏÌØÚÏ×ÁÔÅÌÅÊ"
+
+#: ../roundup/cgi/actions.py:549
+#, 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:577
+#, python-format
+msgid "Edit Error: %s"
+msgstr "ïÛÉÂËÁ ÒÅÄÁËÔÉÒÏ×ÁÎÉÑ: %s"
+
+#: ../roundup/cgi/actions.py:608
+#: ../roundup/cgi/actions.py:619
+#: ../roundup/cgi/actions.py:790
+#: ../roundup/cgi/actions.py:809
+#, python-format
+msgid "Error: %s"
+msgstr "ïÛÉÂËÁ: %s"
+
+#: ../roundup/cgi/actions.py:645
+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:687
+#, python-format
+msgid "Password reset and email sent to %s"
+msgstr "ðÁÒÏÌØ ÓÂÒÏÛÅÎ.  ðÏ ÁÄÒÅÓÕ %s ÏÔÐÒÁ×ÌÅÎÏ ÐÉÓØÍÏ."
+
+#: ../roundup/cgi/actions.py:696
+msgid "Unknown username"
+msgstr "îÅÉÚ×ÅÓÔÎÏÅ ÉÍÑ ÐÏÌØÚÏ×ÁÔÅÌÑ"
+
+#: ../roundup/cgi/actions.py:704
+msgid "Unknown email address"
+msgstr "îÅÉÚ×ÅÓÔÎÙÊ ÁÄÒÅÓ email"
+
+#: ../roundup/cgi/actions.py:709
+msgid "You need to specify a username or address"
+msgstr "÷Ù ÄÏÌÖÎÙ ÕËÁÚÁÔØ ÉÍÑ ÐÏÌØÚÏ×ÁÔÅÌÑ ÉÌÉ ÁÄÒÅÓ email"
+
+#: ../roundup/cgi/actions.py:734
+#, python-format
+msgid "Email sent to %s"
+msgstr "ðÉÓØÍÏ ÏÔÐÒÁ×ÌÅÎÏ ÎÁ %s"
+
+#: ../roundup/cgi/actions.py:753
+msgid "You are now registered, welcome!"
+msgstr "÷Ù ÚÁÒÅÇÉÓÔÒÉÒÏ×ÁÎÙ.  äÏÂÒÏ ÐÏÖÁÌÏ×ÁÔØ!"
+
+#: ../roundup/cgi/actions.py:798
+msgid "It is not permitted to supply roles at registration."
+msgstr "îÅÌØÚÑ ÕËÁÚÙ×ÁÔØ ÒÏÌÉ ÐÒÉ ÒÅÇÉÓÔÒÁÃÉÉ"
+
+#: ../roundup/cgi/actions.py:890
+msgid "You are logged out"
+msgstr "óÅÁÎÓ ÒÁÂÏÔÙ ÚÁ×ÅÒÛÅÎ"
+
+#: ../roundup/cgi/actions.py:907
+msgid "Username required"
+msgstr "îÅ ÕËÁÚÁÎÏ ÉÍÑ ÐÏÌØÚÏ×ÁÔÅÌÑ"
+
+#: ../roundup/cgi/actions.py:942
+#: ../roundup/cgi/actions.py:946
+msgid "Invalid login"
+msgstr "îÅÐÒÁ×ÉÌØÎÙÊ ÐÁÒÏÌØ ÉÌÉ ÉÍÑ ÐÏÌØÚÏ×ÁÔÅÌÑ."
+
+#: ../roundup/cgi/actions.py:952
+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:339
+msgid "Form Error: "
+msgstr "ïÛÉÂËÁ ÆÏÒÍÙ: "
+
+#: ../roundup/cgi/client.py:394
+#, python-format
+msgid "Unrecognized charset: %r"
+msgstr "ëÏÄÉÒÏ×ËÁ %r ÎÅ ÒÁÓÐÏÚÎÁÎÁ"
+
+#: ../roundup/cgi/client.py:522
+msgid "Anonymous users are not allowed to use the web interface"
+msgstr "áÎÏÎÉÍÎÙÍ ÐÏÌØÚÏ×ÁÔÅÌÑÍ ÎÅ ÒÁÚÒÅÛÅÎÏ ÐÏÌØÚÏ×ÁÔØÓÑ ×ÅÂ-ÉÎÔÅÒÆÅÊÓÏÍ."
+
+#: ../roundup/cgi/client.py:677
+msgid "You are not allowed to view this file."
+msgstr "õ ×ÁÓ ÎÅÔ ÒÁÚÒÅÛÅÎÉÑ ÎÁ ÐÒÏÓÍÏÔÒ ÜÔÏÇÏ ÆÁÊÌÁ."
+
+#: ../roundup/cgi/client.py:770
+#, python-format
+msgid "%(starttag)sTime elapsed: %(seconds)fs%(endtag)s\n"
+msgstr "%(starttag)súÁÔÒÁÞÅÎÎÏÅ ×ÒÅÍÑ: %(seconds)fs%(endtag)s\n"
+
+#: ../roundup/cgi/client.py:774
+#, 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 \"%(entry)s\" not a designator"
+msgstr "ÚÎÁÞÅÎÉÅ \"%(entry)s ÓÓÙÌËÉ \"%(key)s\" ÎÅ ÕËÁÚÙ×ÁÅÔ ÎÁ ÏÂßÅËÔ"
+
+#: ../roundup/cgi/form_parser.py:301
+#, python-format
+msgid "%(class)s %(property)s is not a link or multilink property"
+msgstr "áÔÒÉÂÕÔ %(property)s ËÌÁÓÓÁ %(class)s ÎÅ Ñ×ÌÑÅÔÓÑ ÓÓÙÌÏÞÎÙÍ"
+
+#: ../roundup/cgi/form_parser.py:313
+#, python-format
+msgid "The form action claims to require property \"%(property)s\" which doesn't exist"
+msgstr "äÌÑ ×ÙÐÏÌÎÅÎÉÑ ÜÔÏÇÏ ÄÅÊÓÔ×ÉÑ ÔÒÅÂÕÅÔÓÑ ÚÁÐÏÌÎÉÔØ ÁÔÒÉÂÕÔ \"%(property)s\", ÎÏ ÜÔÏÔ ÁÔÒÉÂÕÔ ÎÅ ÓÕÝÅÓÔ×ÕÅÔ."
+
+#: ../roundup/cgi/form_parser.py:335
+#, 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:354
+#: ../roundup/cgi/form_parser.py:380
+#, 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:377
+#: ../roundup/cgi/form_parser.py:383
+msgid "Password and confirmation text do not match"
+msgstr "ðÁÒÏÌÉ ÎÅ ÓÏ×ÐÁÌÉ"
+
+#: ../roundup/cgi/form_parser.py:418
+#, python-format
+msgid "property \"%(propname)s\": \"%(value)s\" not currently in list"
+msgstr "ÁÔÒÉÂÕÔ \"%(propname)s\": ÚÎÁÞÅÎÉÅ \"%(value)s\" ÏÔÓÕÔÓÔ×ÕÅÔ × ÓÐÉÓËÅ"
+
+#: ../roundup/cgi/form_parser.py:551
+#, 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:574
+msgid "File is empty"
+msgstr "æÁÊÌ ÐÕÓÔ"
+
+#: ../roundup/cgi/templating.py:77
+#, python-format
+msgid "You are not allowed to %(action)s items of class %(class)s"
+msgstr "õ ×ÁÓ ÎÅÔ ÒÁÚÒÅÛÅÎÉÑ %(action)s ÄÌÑ ËÌÁÓÓÁ %(class)s"
+
+#: ../roundup/cgi/templating.py:657
+msgid "(list)"
+msgstr "(ÓÐÉÓÏË)"
+
+#: ../roundup/cgi/templating.py:726
+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:740
+#: ../roundup/cgi/templating.py:873
+#: ../roundup/cgi/templating.py:1294
+#: ../roundup/cgi/templating.py:1323
+#: ../roundup/cgi/templating.py:1343
+#: ../roundup/cgi/templating.py:1356
+#: ../roundup/cgi/templating.py:1407
+#: ../roundup/cgi/templating.py:1430
+#: ../roundup/cgi/templating.py:1466
+#: ../roundup/cgi/templating.py:1503
+#: ../roundup/cgi/templating.py:1556
+#: ../roundup/cgi/templating.py:1573
+#: ../roundup/cgi/templating.py:1657
+#: ../roundup/cgi/templating.py:1677
+#: ../roundup/cgi/templating.py:1695
+#: ../roundup/cgi/templating.py:1727
+#: ../roundup/cgi/templating.py:1737
+#: ../roundup/cgi/templating.py:1789
+#: ../roundup/cgi/templating.py:1978
+msgid "[hidden]"
+msgstr "[ÎÅÄÏÓÔÕÐÎÏ]"
+
+#: ../roundup/cgi/templating.py:741
+msgid "New node - no history"
+msgstr "îÏ×ÁÑ ËÁÒÔÏÞËÁ - ÎÅÔ ÉÓÔÏÒÉÉ"
+
+#: ../roundup/cgi/templating.py:855
+msgid "Submit Changes"
+msgstr "éÚÍÅÎÉÔØ"
+
+#: ../roundup/cgi/templating.py:937
+msgid "<em>The indicated property no longer exists</em>"
+msgstr "<em>õËÁÚÁÎÎÙÊ ÁÔÒÉÂÕÔ ÕÖÅ ÎÅ ÓÕÝÅÓÔ×ÕÅÔ.</em>"
+
+#: ../roundup/cgi/templating.py:938
+#, python-format
+msgid "<em>%s: %s</em>\n"
+msgstr ""
+
+#: ../roundup/cgi/templating.py:951
+#, python-format
+msgid "The linked class %(classname)s no longer exists"
+msgstr "ó×ÑÚÑÎÎÙÊ ËÌÁÓÓ %(classname)s ÕÖÅ ÎÅ ÓÕÝÅÓÔ×ÕÅÔ"
+
+# :823
+#: ../roundup/cgi/templating.py:984
+#: ../roundup/cgi/templating.py:1008
+msgid "<strike>The linked node no longer exists</strike>"
+msgstr "<strike>ó×ÑÚÁÎÎÙÊ ÏÂßÅËÔ ÕÖÅ ÎÅ ÓÕÝÅÓÔ×ÕÅÔ</strike>"
+
+#: ../roundup/cgi/templating.py:1061
+#, python-format
+msgid "%s: (no value)"
+msgstr "%s: (ÎÅÔ ÚÎÁÞÅÎÉÑ)"
+
+#: ../roundup/cgi/templating.py:1073
+msgid "<strong><em>This event is not handled by the history display!</em></strong>"
+msgstr "<strong><em>îÅÉÚ×ÅÓÔÎÙÊ ÔÉРÓÏÂÙÔÉÑ!</em></strong>"
+
+#: ../roundup/cgi/templating.py:1085
+msgid "<tr><td colspan=4><strong>Note:</strong></td></tr>"
+msgstr "<tr><td colspan=4><strong>ðÒÉÍÅÞÁÎÉÅ:</strong></td></tr>"
+
+#: ../roundup/cgi/templating.py:1094
+msgid "History"
+msgstr "éÓÔÏÒÉÑ"
+
+#: ../roundup/cgi/templating.py:1096
+msgid "<th>Date</th>"
+msgstr "<th>äÁÔÁ</th>"
+
+#: ../roundup/cgi/templating.py:1097
+msgid "<th>User</th>"
+msgstr "<th>ðÏÌØÚÏ×ÁÔÅÌØ</th>"
+
+#: ../roundup/cgi/templating.py:1098
+msgid "<th>Action</th>"
+msgstr "<th>äÅÊÓÔ×ÉÅ</th>"
+
+#: ../roundup/cgi/templating.py:1099
+msgid "<th>Args</th>"
+msgstr "<th>ðÁÒÁÍÅÔÒÙ</th>"
+
+#: ../roundup/cgi/templating.py:1141
+#, python-format
+msgid "Copy of %(class)s %(id)s"
+msgstr "ëÏÐÉÑ: %(class)s %(id)s"
+
+#: ../roundup/cgi/templating.py:1434
+msgid "*encrypted*"
+msgstr "*ÚÁÛÉÆÒÏ×ÁÎ*"
+
+#: ../roundup/cgi/templating.py:1507
+#: ../roundup/cgi/templating.py:1528
+#: ../roundup/cgi/templating.py:1534
+#: ../roundup/cgi/templating.py:1050
+msgid "No"
+msgstr "îÅÔ"
+
+#: ../roundup/cgi/templating.py:1507
+#: ../roundup/cgi/templating.py:1526
+#: ../roundup/cgi/templating.py:1531
+#: ../roundup/cgi/templating.py:1050
+msgid "Yes"
+msgstr "äÁ"
+
+#: ../roundup/cgi/templating.py:1620
+msgid "default value for DateHTMLProperty must be either DateHTMLProperty or string date representation."
+msgstr "ÚÎÁÞÅÎÉÅ ÐÏ ÕÍÏÌÞÁÎÉÀ ÄÌÑ DateHTMLProperty ÄÏÌÖÎÏ ÂÙÔØ ÏÂßÅËÔÏÍ DateHTMLProperty ÉÌÉ ÓÔÒÏËÏ×ÙÍ ÐÒÅÄÓÔÁ×ÌÅÎÉÅÍ ÄÁÔÙ."
+
+#: ../roundup/cgi/templating.py:1780
+#, python-format
+msgid "Attempt to look up %(attr)s on a missing value"
+msgstr "ðÏÐÙÔËÁ ÐÏÌÕÞÉÔØ ÁÔÒÉÂÕÔ \"%(attr)s\" ÎÅÓÕÝÅÓÔ×ÕÀÝÅÇÏ ÏÂßÅËÔÁ"
+
+#: ../roundup/cgi/templating.py:1853
+#, python-format
+msgid "<option %svalue=\"-1\">- no selection -</option>"
+msgstr "<option %svalue=\"-1\">- ÎÅ ÕËÁÚÁÎÏ -</option>"
+
+#: ../roundup/date.py:300
+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:359
+#, 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:666
+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:685
+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:822
+#, 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:826
+#, 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:830
+#, 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:834
+#, 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:838
+msgid "tomorrow"
+msgstr "ÚÁ×ÔÒÁ"
+
+#: ../roundup/date.py:840
+msgid "yesterday"
+msgstr "×ÞÅÒÁ"
+
+#: ../roundup/date.py:843
+#, 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:847
+msgid "an hour"
+msgstr "ÞÁÓ"
+
+#: ../roundup/date.py:849
+msgid "1 1/2 hours"
+msgstr "ÐÏÌÔÏÒÁ ÞÁÓÁ"
+
+# third form ain't used
+#: ../roundup/date.py:851
+#, 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:855
+msgid "in a moment"
+msgstr "ÓÅÊÞÁÓ"
+
+#: ../roundup/date.py:857
+msgid "just now"
+msgstr "ÔÏÌØËÏ ÞÔÏ"
+
+# ÉÓÐÏÌØÚÕÅÔÓÑ × ×ÙÒÁÖÅÎÉÑÈ "ÞÅÒÅÚ ÍÉÎÕÔÕ" ÉÌÉ "ÍÉÎÕÔÕ ÎÁÚÁÄ"
+#: ../roundup/date.py:860
+msgid "1 minute"
+msgstr "ÍÉÎÕÔÕ"
+
+# ÉÓÐÏÌØÚÕÅÔÓÑ × ×ÙÒÁÖÅÎÉÑÈ "ÞÅÒÅÚ 2 ÍÉÎÕÔÙ" ÉÌÉ "2 ÍÉÎÕÔÙ ÎÁÚÁÄ"
+#: ../roundup/date.py:863
+#, 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:866
+msgid "1/2 an hour"
+msgstr "ÐÏÌÞÁÓÁ"
+
+#: ../roundup/date.py:868
+#, 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:872
+#, python-format
+msgid "%s ago"
+msgstr "%s ÎÁÚÁÄ"
+
+#: ../roundup/date.py:874
+#, 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/mailgw.py:584
+msgid ""
+"\n"
+"Emails to Roundup trackers must include a Subject: line!\n"
+msgstr ""
+"\n"
+"÷ ÐÉÓØÍÁÈ ÄÌÑ ÔÒÅËÅÒÁ Roundup ÄÏÌÖÎÁ ÂÙÔØ ÕËÁÚÁÎÁ ÔÅÍÁ ÓÏÏÂÝÅÎÉÑ (Subject).\n"
+
+#: ../roundup/mailgw.py:708
+#, python-format
+msgid ""
+"\n"
+"The message you sent to roundup did not contain a properly formed subject\n"
+"line. The subject must contain a class name or designator to indicate the\n"
+"'topic' of the message. For example:\n"
+"    Subject: [issue] This is a new issue\n"
+"      - this will create a new issue in the tracker with the title 'This is\n"
+"        a new issue'.\n"
+"    Subject: [issue1234] This is a followup to issue 1234\n"
+"      - this will append the message's contents to the existing issue 1234\n"
+"        in the tracker.\n"
+"\n"
+"Subject was: '%(subject)s'\n"
+msgstr ""
+"\n"
+"÷Ù ÐÏÓÌÁÌÉ ÔÒÅËÅÒÕ Roundup ÓÏÏÂÝÅÎÉÅ, × ËÏÔÏÒÏÍ ÎÅÐÒÁ×ÉÌØÎÏ ÚÁÐÏÌÎÅÎÁ\n"
+"ÔÅÍÁ ÐÉÓØÍÁ - ÐÏÌÅ \"Subject:\".  ÷ ÜÔÏÍ ÐÏÌÅ × Ë×ÁÄÒÁÔÎÙÈ ÓËÏÂËÁÈ\n"
+"ÄÏÌÖÎÅΠÂÙÔØ ÕËÁÚÁΠËÌÁÓÓ ÉÌÉ ÏÐÉÓÁÔÅÌØ ÏÂßÅËÔÁ, Ë ËÏÔÏÒÏÍÕ ÏÔÎÏÓÉÔÓÑ\n"
+"ÜÔÏ ÓÏÏÂÝÅÎÉÅ.  îÁÐÒÉÍÅÒ:\n"
+"    Subject: [issue] üÔÏ ÎÏ×ÁÑ ÚÁÄÁÞÁ\n"
+"      - ÔÁËÏÅ ÐÉÓØÍÏ ÓÏÚÄÁÓÔ × ÔÒÅËÅÒÅ ÎÏ×ÕÀ ÚÁÄÁÞÕ (ÏÂßÅËÔ ËÌÁÓÓÁ issue)\n"
+"        Ó ÚÁÇÏÌÏ×ËÏÍ \"üÔÏ ÎÏ×ÁÑ ÚÁÄÁÞÁ\".\n"
+"    Subject: [issue1234] üÔÏ ÚÁÍÅÞÁÎÉÅ Ë ÚÁÄÁÞÅ 1234\n"
+"      - ÓÏÄÅÒÖÉÍÏÅ ÜÔÏÇÏ ÐÉÓØÍÁ ÂÕÄÅÔ ÄÏÂÁ×ÌÅÎÏ Ë ÓÐÉÓËÕ ÓÏÏÂÝÅÎÉÊ ÚÁÄÁÞÉ\n"
+"        1234, ËÏÔÏÒÁÑ ÕÖÅ ÓÕÝÅÓÔ×ÕÅÔ × ÔÒÅËÅÒÅ.\n"
+"ôÅÍÁ ×ÁÛÅÇÏ ÐÉÓØÍÁ: \"%(subject)s\"\n"
+
+#: ../roundup/mailgw.py:746
+#, python-format
+msgid ""
+"\n"
+"The class name you identified in the subject line (\"%(classname)s\") does\n"
+"not exist in the database.\n"
+"\n"
+"Valid class names are: %(validname)s\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"\n"
+"éÍÑ ËÌÁÓÓÁ, ÕËÁÚÁÎÎÏÅ × ÔÅÍÅ ÓÏÏÂÝÅÎÉÑ (\"%(classname)s\"),\n"
+"ÎÅ ÏÐÒÅÄÅÌÅÎÏ × ÂÁÚÅ ÄÁÎÎÙÈ.\n"
+"\n"
+"éÍÅÎÁ ÓÕÝÅÓÔ×ÕÀÝÉÈ ËÌÁÓÓÏ×: %(validname)s\n"
+"ôÅÍÁ ×ÁÛÅÇÏ ÐÉÓØÍÁ: \"%(subject)s\"\n"
+
+#: ../roundup/mailgw.py:754
+#, python-format
+msgid ""
+"\n"
+"You did not identify a class name in the subject line and there is no\n"
+"default set for this tracker. The subject must contain a class name or\n"
+"designator to indicate the 'topic' of the message. For example:\n"
+"    Subject: [issue] This is a new issue\n"
+"      - this will create a new issue in the tracker with the title 'This is\n"
+"        a new issue'.\n"
+"    Subject: [issue1234] This is a followup to issue 1234\n"
+"      - this will append the message's contents to the existing issue 1234\n"
+"        in the tracker.\n"
+"\n"
+"Subject was: '%(subject)s'\n"
+msgstr ""
+"\n"
+"÷Ù ÎÅ ÕËÁÚÁÌÉ × ÔÅÍÅ ÐÉÓØÍÁ ÉÍÅÎÉ ËÌÁÓÓÁ, É ÚÎÁÞÅÎÉÅ ÐÏ ÕÍÏÌÞÁÎÉÀ\n"
+"ÎÅ ÕÓÔÁÎÏ×ÌÅÎÏ ÄÌÑ ÜÔÏÇÏ ÔÒÅËÅÒÁ.  ÷ ÐÏÌÅ \"Subject:\" × Ë×ÁÄÒÁÔÎÙÈ\n"
+"ÓËÏÂËÁÈ ÄÏÌÖÎÅΠÂÙÔØ ÕËÁÚÁΠËÌÁÓÓ ÉÌÉ ÏÐÉÓÁÔÅÌØ ÏÂßÅËÔÁ, Ë ËÏÔÏÒÏÍÕ\n"
+"ÏÔÎÏÓÉÔÓÑ ÜÔÏ ÓÏÏÂÝÅÎÉÅ.  îÁÐÒÉÍÅÒ:\n"
+"    Subject: [issue] üÔÏ ÎÏ×ÁÑ ÚÁÄÁÞÁ\n"
+"      - ÔÁËÏÅ ÐÉÓØÍÏ ÓÏÚÄÁÓÔ × ÔÒÅËÅÒÅ ÎÏ×ÕÀ ÚÁÄÁÞÕ (ÏÂßÅËÔ ËÌÁÓÓÁ issue)\n"
+"        Ó ÚÁÇÏÌÏ×ËÏÍ \"üÔÏ ÎÏ×ÁÑ ÚÁÄÁÞÁ\".\n"
+"    Subject: [issue1234] üÔÏ ÚÁÍÅÞÁÎÉÅ Ë ÚÁÄÁÞÅ 1234\n"
+"      - ÓÏÄÅÒÖÉÍÏÅ ÜÔÏÇÏ ÐÉÓØÍÁ ÂÕÄÅÔ ÄÏÂÁ×ÌÅÎÏ Ë ÓÐÉÓËÕ ÓÏÏÂÝÅÎÉÊ ÚÁÄÁÞÉ\n"
+"        1234, ËÏÔÏÒÁÑ ÕÖÅ ÓÕÝÅÓÔ×ÕÅÔ × ÔÒÅËÅÒÅ.\n"
+"\n"
+"ôÅÍÁ ×ÁÛÅÇÏ ÐÉÓØÍÁ: \"%(subject)s\"\n"
+
+#: ../roundup/mailgw.py:795
+#, python-format
+msgid ""
+"\n"
+"I cannot match your message to a node in the database - you need to either\n"
+"supply a full designator (with number, eg \"[issue123]\") or keep the\n"
+"previous subject title intact so I can match that.\n"
+"\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"\n"
+"ôÒÅËÅÒ ÎÅ ÓÍÏÇ ÐÒÉÓÏÅÄÉÎÉÔØ ×ÁÛÅ ÓÏÏÂÝÅÎÉÅ Ë ÓÕÝÅÓÔ×ÕÀÝÅÍÕ ÏÂßÅËÔÕ\n"
+"ÂÁÚÙ ÄÁÎÎÙÈ - ×Ù ÄÏÌÖÎÙ ÉÌÉ ÕËÁÚÁÔØ ÐÏÌÎÙÊ ÏÐÉÓÁÔÅÌØ ÏÂßÅËÔÁ\n"
+"(Ó ÎÏÍÅÒÏÍ, ÎÁÐÒ.: \"[issue123]\"), ÉÌÉ ÐÒÉ ÏÔ×ÅÔÅ ÓÏÈÒÁÎÉÔØ\n"
+"ÂÅÚ ÉÚÍÅÎÅÎÉÊ ÔÅÍÕ ÐÉÓØÍÁ, ÞÔÏÂÙ Roundup ÓÍÏÇ ÓÏÏÔÎÅÓÔÉ ÅÅ\n"
+"Ó ÚÁÇÏÌÏ×ËÏÍ ÏÂßÅËÔÁ.\n"
+"\n"
+"ôÅÍÁ ×ÁÛÅÇÏ ÐÉÓØÍÁ: \"%(subject)s\"\n"
+
+#: ../roundup/mailgw.py:828
+#, python-format
+msgid ""
+"\n"
+"The node specified by the designator in the subject of your message\n"
+"(\"%(nodeid)s\") does not exist.\n"
+"\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"\n"
+"ïÂßÅËÔ, ÕËÁÚÁÎÎÙÊ × ÔÅÍÅ ×ÁÛÅÇÏ ÓÏÏÂÝÅÎÉÑ, - \"%(nodeid)s\" - ÎÅ ÓÕÝÅÓÔ×ÕÅÔ.\n"
+"\n"
+"ôÅÍÁ ×ÁÛÅÇÏ ÐÉÓØÍÁ: \"%(subject)s\"\n"
+
+#: ../roundup/mailgw.py:856
+#, python-format
+msgid ""
+"\n"
+"The mail gateway is not properly set up. Please contact\n"
+"%(mailadmin)s and have them fix the incorrect class specified as:\n"
+"  %(current_class)s\n"
+msgstr ""
+"\n"
+"ïÂÎÁÒÕÖÅÎÁ ÏÛÉÂËÁ × ÎÁÓÔÒÏÊËÅ ÐÏÞÔÏ×ÏÇÏ ÛÌÀÚÁ.\n"
+"óÏÏÂÝÉÔÅ, ÐÏÖÁÌÕÊÓÔÁ, ÎÁ ÁÄÒÅÓ %(mailadmin)s\n"
+"Ï ÎÅÐÒÁ×ÉÌØÎÏ ÏÐÉÓÁÎÎÏÍ ËÌÁÓÓÅ:\n"
+"  %(current_class)s\n"
+
+#: ../roundup/mailgw.py:879
+#, python-format
+msgid ""
+"\n"
+"The mail gateway is not properly set up. Please contact\n"
+"%(mailadmin)s and have them fix the incorrect properties:\n"
+"  %(errors)s\n"
+msgstr ""
+"\n"
+"ïÂÎÁÒÕÖÅÎÁ ÏÛÉÂËÁ × ÎÁÓÔÒÏÊËÅ ÐÏÞÔÏ×ÏÇÏ ÛÌÀÚÁ.\n"
+"óÏÏÂÝÉÔÅ, ÐÏÖÁÌÕÊÓÔÁ, ÎÁ ÁÄÒÅÓ %(mailadmin)s\n"
+"Ï ÎÅÐÒÁ×ÉÌØÎÏ ÏÐÉÓÁÎÎÙÈ ÁÔÒÉÂÕÔÁÈ:\n"
+"  %(errors)s\n"
+
+#: ../roundup/mailgw.py:919
+#, python-format
+msgid ""
+"\n"
+"You are not a registered user.%(registration_info)s\n"
+"\n"
+"Unknown address: %(from_address)s\n"
+msgstr ""
+"\n"
+"äÏÓÔÕРÒÁÚÒÅÛÅΠÔÏÌØËÏ ÚÁÒÅÇÉÓÔÒÉÒÏ×ÁÎÎÙÍ ÐÏÌØÚÏ×ÁÔÅÌÑÍ.%(registration_info)s\n"
+"\n"
+"îÅÉÚ×ÅÓÔÎÙÊ ÁÄÒÅÓ: %(from_address)s\n"
+
+#: ../roundup/mailgw.py:927
+msgid "You are not permitted to access this tracker."
+msgstr "õ ×ÁÓ ÎÅÔ ÒÁÚÒÅÛÅÎÉÑ ÎÁ ÄÏÓÔÕРˠÜÔÏÍÕ ÔÒÅËÅÒÕ."
+
+#: ../roundup/mailgw.py:934
+#, python-format
+msgid "You are not permitted to edit %(classname)s."
+msgstr "õ ×ÁÓ ÎÅÔ ÒÁÚÒÅÛÅÎÉÑ ÒÅÄÁËÔÉÒÏ×ÁÔØ %(classname)s"
+
+#: ../roundup/mailgw.py:938
+#, python-format
+msgid "You are not permitted to create %(classname)s."
+msgstr "õ ×ÁÓ ÎÅÔ ÒÁÚÒÅÛÅÎÉÑ ÓÏÚÄÁ×ÁÔØ ÏÂßÅËÔÙ %(classname)s"
+
+#: ../roundup/mailgw.py:985
+#, python-format
+msgid ""
+"\n"
+"There were problems handling your subject line argument list:\n"
+"- %(errors)s\n"
+"\n"
+"Subject was: \"%(subject)s\"\n"
+msgstr ""
+"\n"
+"ðÒÉ ÏÂÒÁÂÏÔËÅ ÁÒÇÕÍÅÎÔÏ×, ÕËÁÚÁÎÎÙÈ × ÔÅÍÅ ×ÁÛÅÇÏ ÐÉÓØÍÁ, ÐÒÏÉÚÏÛÌÉ ÏÛÉÂËÉ:\n"
+"- %(errors)s\n"
+"\n"
+"ôÅÍÁ ÐÉÓØÍÁ: \"%(subject)s\"\n"
+
+#: ../roundup/mailgw.py:1013
+msgid ""
+"\n"
+"Roundup requires the submission to be plain text. The message parser could\n"
+"not find a text/plain part to use.\n"
+msgstr ""
+"\n"
+"óÏÏÂÝÅÎÉÑ ÄÌÑ Roundup ÄÏÌÖÎÙ ÂÙÔØ × ÔÅËÓÔÏ×ÏÍ ÆÏÒÍÁÔÅ.\n"
+"÷ ×ÁÛÅÍ ÓÏÏÂÝÅÎÉÉ ÎÅ ÎÁÊÄÅÎÁ ÞÁÓÔØ ÆÏÒÍÁÔÁ text/plain.\n"
+
+#: ../roundup/mailgw.py:1030
+msgid "You are not permitted to create files."
+msgstr "õ ×ÁÓ ÎÅÔ ÒÁÚÒÅÛÅÎÉÑ ÎÁ ÓÏÚÄÁÎÉÅ ÆÁÊÌÏ×."
+
+#: ../roundup/mailgw.py:1044
+#, python-format
+msgid "You are not permitted to add files to %(classname)s."
+msgstr "õ ×ÁÓ ÎÅÔ ÒÁÚÒÅÛÅÎÉÑ ÄÏÂÁ×ÌÑÔØ ÆÁÊÌÙ ÄÌÑ ËÌÁÓÓÁ %(classname)s."
+
+#: ../roundup/mailgw.py:1062
+msgid "You are not permitted to create messages."
+msgstr "õ ×ÁÓ ÎÅÔ ÒÁÚÒÅÛÅÎÉÑ ÎÁ ÓÏÚÄÁÎÉÅ ÓÏÏÂÝÅÎÉÊ"
+
+#: ../roundup/mailgw.py:1070
+#, python-format
+msgid ""
+"\n"
+"Mail message was rejected by a detector.\n"
+"%(error)s\n"
+msgstr ""
+"\n"
+"óÏÏÂÝÅÎÉÅ ÏÔÂÒÏÛÅÎÏ ÄÅÔÅËÔÏÒÏÍ.\n"
+"%(error)s\n"
+
+#: ../roundup/mailgw.py:1078
+#, python-format
+msgid "You are not permitted to add messages to %(classname)s."
+msgstr "õ ×ÁÓ ÎÅÔ ÒÁÚÒÅÛÅÎÉÑ ÄÏÂÁ×ÌÑÔØ ÓÏÏÂÝÅÎÉÑ ÄÌÑ ËÌÁÓÓÁ %(classname)s."
+
+#: ../roundup/mailgw.py:1105
+#, python-format
+msgid "You are not permitted to edit property %(prop)s of class %(classname)s."
+msgstr "õ ×ÁÓ ÎÅÔ ÒÁÚÒÅÛÅÎÉÑ ÉÚÍÅÎÑÔØ ÁÔÒÉÂÕÔ %(prop)s ËÌÁÓÓÁ %(classname)s"
+
+#: ../roundup/mailgw.py:1113
+#, python-format
+msgid ""
+"\n"
+"There was a problem with the message you sent:\n"
+"   %(message)s\n"
+msgstr ""
+"\n"
+"ðÒÉ ÏÂÒÁÂÏÔËÅ ×ÁÛÅÇÏ ÓÏÏÂÝÅÎÉÑ ÐÒÏÉÚÏÛÌÁ ÏÛÉÂËÁ:\n"
+"   %(message)s\n"
+
+#: ../roundup/mailgw.py:1135
+msgid "not of form [arg=value,value,...;arg=value,value,...]"
+msgstr "ÁÒÇÕÍÅÎÔÙ ÄÏÌÖÎÙ ÂÙÔØ × ÆÏÒÍÁÔÅ [ÉÍÑ=ÚÎÁÞÅÎÉÅ,ÚÎÁÞÅÎÉÅ,...;ÉÍÑ=ÚÎÁÞÅÎÉÅ,ÚÎÁÞÅÎÉÅ,...]"
+
+#: ../roundup/roundupdb.py:147
+msgid "files"
+msgstr "ÆÁÊÌÙ"
+
+#: ../roundup/roundupdb.py:147
+msgid "messages"
+msgstr "ÓÏÏÂÝÅÎÉÑ"
+
+#: ../roundup/roundupdb.py:147
+msgid "nosy"
+msgstr "ÉÚ×ÅÝÅÎÉÑ"
+
+#: ../roundup/roundupdb.py:147
+msgid "superseder"
+msgstr "ÚÁÍÅÝÅÎÉÅ"
+
+#: ../roundup/roundupdb.py:147
+msgid "title"
+msgstr "ÚÁÇÌÁ×ÉÅ"
+
+#: ../roundup/roundupdb.py:148
+msgid "assignedto"
+msgstr "ÉÓÐÏÌÎÉÔÅÌØ"
+
+#: ../roundup/roundupdb.py:148
+msgid "keyword"
+msgstr "ËÌÀÞÅ×ÏÅ ÓÌÏ×Ï"
+
+#: ../roundup/roundupdb.py:148
+msgid "priority"
+msgstr "ÐÒÉÏÒÉÔÅÔ"
+
+#: ../roundup/roundupdb.py:148
+msgid "status"
+msgstr "ÓÔÁÔÕÓ"
+
+#: ../roundup/roundupdb.py:151
+msgid "activity"
+msgstr "ÄÅÊÓÔ×ÉÅ"
+
+#. following properties are common for all hyperdb classes
+#. they are listed here to keep things in one place
+#: ../roundup/roundupdb.py:151
+msgid "actor"
+msgstr "×ÙÐÏÌÎÉÌ"
+
+#: ../roundup/roundupdb.py:151
+msgid "creation"
+msgstr "ÄÁÔÁ ÓÏÚÄÁÎÉÑ"
+
+#: ../roundup/roundupdb.py:151
+msgid "creator"
+msgstr "Á×ÔÏÒ"
+
+#: ../roundup/roundupdb.py:309
+#, python-format
+msgid "New submission from %(authname)s%(authaddr)s:"
+msgstr "îÏ×ÏÅ ÐÏÓÔÕÐÌÅÎÉÅ ÏÔ %(authname)s%(authaddr)s:"
+
+#: ../roundup/roundupdb.py:312
+#, python-format
+msgid "%(authname)s%(authaddr)s added the comment:"
+msgstr "%(authname)s%(authaddr)s ÄÏÂÁ×ÉÌ ÚÁÍÅÞÁÎÉÅ:"
+
+#: ../roundup/roundupdb.py:315
+#, python-format
+msgid "Change by %(authname)s%(authaddr)s:"
+msgstr "éÚÍÅÎÅÎÉÅ %(authname)s%(authaddr)s:"
+
+#: ../roundup/roundupdb.py:342
+#, python-format
+msgid "File '%(filename)s' not attached - you can download it from %(link)s."
+msgstr "æÁÊÌ '%(filename)s' ÎÅ ×ÌÏÖÅΠ- ×Ù ÍÏÖÅÔÅ ÓËÁÞÁÔØ ÅÇÏ ÐÏ ÁÄÒÅÓÕ %(link)s."
+
+#: ../roundup/roundupdb.py:615
+#, python-format
+msgid ""
+"\n"
+"Now:\n"
+"%(new)s\n"
+"Was:\n"
+"%(old)s"
+msgstr ""
+"\n"
+"âÙÌÏ:\n"
+"%(old)s\n"
+"óÔÁÌÏ:\n"
+"%(new)s"
+
+#: ../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 class] [[-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@server\n"
+" The username and password may be omitted:\n"
+"    pop username@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"
+"POPS:\n"
+" Connect to a POP server over ssl. This requires python 2.4 or later.\n"
+" This supports the same notation as POP.\n"
+"\n"
+"APOP:\n"
+" Same as POP, but using Authenticated POP:\n"
+"    apop username:password@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@server\n"
+" It also allows you to specify a specific mailbox other than INBOX using\n"
+" this format:\n"
+"    imap username:password@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@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@server\n"
+" éÍÑ ÐÏÌØÚÏ×ÁÔÅÌÑ É ÐÁÒÏÌØ ÍÏÇÕÔ ÂÙÔØ ÏÐÕÝÅÎÙ:\n"
+"    pop username@server\n"
+"    pop server\n"
+" åÓÌÉ ÉÍÑ ÉÌÉ ÐÁÒÏÌØ ÎÅ ÕËÁÚÁÎÙ, ÐÒÏÇÒÁÍÍÁ ÐÏÐÒÏÓÉÔ ××ÅÓÔÉ ÉÈ Ó ÔÅÒÍÉÎÁÌÁ.\n"
+"\n"
+"POPS:\n"
+" óÏÅÄÉÎÅÎÉÅ Ó POP3-ÓÅÒ×ÅÒÏÍ ÞÅÒÅÚ SSL.  äÌÑ ÜÔÏÇÏ ÓÏÅÄÉÎÅÎÉÑ ÔÒÅÂÕÅÔÓÑ\n"
+" python 2.4 ÉÌÉ ÂÏÌÅÅ ÎÏ×ÙÊ.  óÅÒ×ÅÒ, ÉÍÑ ÐÏÌØÚÏ×ÁÔÅÌÑ É ÐÁÒÏÌØ ÕËÁÚÙ×ÁÀÔÓÑ\n"
+" ÔÁË ÖÅ, ËÁË É ÄÌÑ ÏÂÙÞÎÏÇÏ ÓÅÒ×ÅÒÁ POP.\n"
+"\n"
+"APOP:\n"
+" ôÏ ÖÅ, ÞÔÏ É POP, ÎÏ Ó ÉÓÐÏÌØÚÏ×ÁÎÉÅÍ ÐÒÏÔÏËÏÌÁ Authenticated POP:\n"
+"    apop username:password@server\n"
+"\n"
+"IMAP:\n"
+" ðÏÌÕÞÉÔØ ÐÏÞÔÕ Ó ÓÅÒ×ÅÒÁ IMAP.  ðÏÞÔÏ×ÙÊ ÑÝÉË ÕËÁÚÙ×ÁÅÔÓÑ ÔÁË ÖÅ,\n"
+" ËÁË É ÄÌÑ POP-ÓÅÒ×ÅÒÁ:\n"
+"    imap username:password@server\n"
+" ðÒÉ ÉÓÐÏÌØÚÏ×ÁÎÉÉ IMAP ÍÏÖÎÏ ÕËÁÚÁÔØ ÉÍÑ ÐÁÐËÉ ×ÈÏÄÎÏÊ ÐÏÞÔÙ,\n"
+" ÅÓÌÉ ÏÎÏ ÏÔÌÉÞÁÅÔÓÑ ÏÔ ÏÂÙÞÎÏÇÏ (\"INBOX\"):\n"
+"    imap username:password@server folder\n"
+"\n"
+"IMAPS:\n"
+" ðÏÌÕÞÉÔØ ÐÏÞÔÕ Ó ÓÅÒ×ÅÒÁ IMAP, ÐÏÚ×ÏÌÑÀÝÅÇÏ ÛÉÆÒÏ×ÁÎÎÙÅ ÓÏÅÄÉÎÅÎÉÑ.\n"
+" ðÏÞÔÏ×ÙÊ ÑÝÉË ÕËÁÚÙ×ÁÅÔÓÑ ÔÁË ÖÅ, ËÁË É ÄÌÑ ÏÂÙÞÎÏÇÏ IMAP-ÓÅÒ×ÅÒÁ:\n"
+"    imaps username:password@server [mailbox]\n"
+"\n"
+
+#: ../roundup/scripts/roundup_mailgw.py:151
+msgid "Error: not enough source specification information"
+msgstr "ïÛÉÂËÁ: ÎÅ ÕËÁÚÁΠÐÕÔØ Ë ÐÏÞÔÏ×ÏÍÕ ÑÝÉËÕ"
+
+#: ../roundup/scripts/roundup_mailgw.py:167
+msgid "Error: a later version of python is required"
+msgstr "ïÛÉÂËÁ: ÔÒÅÂÕÅÔÓÑ ÂÏÌÅÅ ÎÏ×ÁÑ ×ÅÒÓÉÑ Python"
+
+#: ../roundup/scripts/roundup_mailgw.py:170
+msgid "Error: pop specification not valid"
+msgstr "ïÛÉÂËÁ: ÎÅÐÒÁ×ÉÌØÎÙÊ ÁÄÒÅÓ pop-ÓÅÒ×ÅÒÁ"
+
+#: ../roundup/scripts/roundup_mailgw.py:177
+msgid "Error: apop specification not valid"
+msgstr "ïÛÉÂËÁ: ÎÅÐÒÁ×ÉÌØÎÙÊ ÁÄÒÅÓ apop-ÓÅÒ×ÅÒÁ"
+
+#: ../roundup/scripts/roundup_mailgw.py:189
+msgid "Error: The source must be either \"mailbox\", \"pop\", \"apop\", \"imap\" or \"imaps\""
+msgstr "ïÛÉÂËÁ: ôÉРÐÏÞÔÏ×ÏÇÏ ÑÝÉËÁ ÄÏÌÖÅΠÂÙÔØ \"mailbox\", \"pop\", \"apop\", \"imap\" ÉÌÉ \"imaps\""
+
+#: ../roundup/scripts/roundup_server.py:76
+msgid "WARNING: generating temporary SSL certificate"
+msgstr "÷îéíáîéå: ÓÏÚÄÁÅÔÓÑ ×ÒÅÍÅÎÎÙÊ ÓÅÒÔÉÆÉËÁÔ ÄÌÑ SSL"
+
+#: ../roundup/scripts/roundup_server.py:253
+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:389
+#, python-format
+msgid "Error: %s: %s"
+msgstr "ïÛÉÂËÁ: %s: %s"
+
+#: ../roundup/scripts/roundup_server.py:399
+msgid "WARNING: ignoring \"-g\" argument, not root"
+msgstr "÷îéíáîéå: ÐÁÒÁÍÅÔÒ \"-g\" ÎÅ ÉÓÐÏÌØÚÕÅÔÓÑ, ÏΠÒÁÚÒÅÛÅΠÔÏÌØËÏ ÄÌÑ ÐÏÌØÚÏ×ÁÔÅÌÑ root"
+
+#: ../roundup/scripts/roundup_server.py:405
+msgid "Can't change groups - no grp module"
+msgstr "ðÏÄÍÅÎÁ ÇÒÕÐÐÙ ÎÅ×ÏÚÍÏÖÎÁ - ÎÕÖÅΠÍÏÄÕÌØ grp"
+
+#: ../roundup/scripts/roundup_server.py:414
+#, python-format
+msgid "Group %(group)s doesn't exist"
+msgstr "çÒÕÐÐÁ %(group)s ÎÅ ÓÕÝÅÓÔ×ÕÅÔ"
+
+#: ../roundup/scripts/roundup_server.py:425
+msgid "Can't run as root!"
+msgstr "úÁÐÕÓË ÓÅÒ×ÅÒÁ Ó ÐÏÌÎÏÍÏÞÉÑÍÉ ÐÏÌØÚÏ×ÁÔÅÌÑ root ÚÁÐÒÅÝÅÎ!"
+
+#: ../roundup/scripts/roundup_server.py:428
+msgid "WARNING: ignoring \"-u\" argument, not root"
+msgstr "÷îéíáîéå: ÐÁÒÁÍÅÔÒ \"-u\" ÎÅ ÉÓÐÏÌØÚÕÅÔÓÑ, ÏΠÒÁÚÒÅÛÅΠÔÏÌØËÏ ÄÌÑ ÐÏÌØÚÏ×ÁÔÅÌÑ root"
+
+#: ../roundup/scripts/roundup_server.py:434
+msgid "Can't change users - no pwd module"
+msgstr "ðÏÄÍÅÎÁ ÐÏÌØÚÏ×ÁÔÅÌÑ ÎÅ×ÏÚÍÏÖÎÁ - ÎÕÖÅΠÍÏÄÕÌØ pwd"
+
+#: ../roundup/scripts/roundup_server.py:443
+#, python-format
+msgid "User %(user)s doesn't exist"
+msgstr "ðÏÌØÚÏ×ÁÔÅÌØ %(user)s ÎÅ ÓÕÝÅÓÔ×ÕÅÔ"
+
+#: ../roundup/scripts/roundup_server.py:592
+#, python-format
+msgid "Multiprocess mode \"%s\" is not available, switching to single-process"
+msgstr "òÅÖÉÍ \"%s\" ÎÅÄÏÓÔÕÐÅÎ, ÐÅÒÅËÌÀÞÁÅÍÓÑ × ÏÄÎÏÚÁÄÁÞÎÙÊ ÒÅÖÉÍ"
+
+#: ../roundup/scripts/roundup_server.py:620
+#, python-format
+msgid "Unable to bind to port %s, port already in use."
+msgstr "îÅ×ÏÚÍÏÖÎÏ ÕÓÔÁÎÏ×ÉÔØ ÓÅÒ×ÅÒ ÎÁ ÐÏÒÔÕ %s, ÐÏÒÔ ÕÖÅ ÚÁÎÑÔ."
+
+#: ../roundup/scripts/roundup_server.py:688
+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:695
+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:702
+#, 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"
+" -i <fname>    set tracker index template\n"
+" -s            enable SSL\n"
+" -e <fname>    PEM file containing SSL key and certificate\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"
+" -i <fname>    ÕËÁÚÁÔØ ÛÁÂÌÏΠÄÌÑ ÓÐÉÓËÁ ÔÒÅËÅÒÏ×\n"
+" -s            ×ËÌÀÞÉÔØ SSL\n"
+" -e <fname>    PEM-ÆÁÊÌ, ÓÏÄÅÒÖÁÝÉÊ ËÌÀÞ É ÓÅÒÔÉÆÉËÁÔ ÄÌÑ SSL\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:860
+msgid "Instances must be name=home"
+msgstr "óÐÉÓÏË ÔÒÅËÅÒÏ× ÄÏÌÖÅΠÂÙÔØ × ÆÏÒÍÁÔÅ ÉÍÑ=ËÁÔÁÌÏÇ"
+
+#: ../roundup/scripts/roundup_server.py:874
+#, python-format
+msgid "Configuration saved to %s"
+msgstr "ëÏÎÆÉÇÕÒÁÃÉÑ ÚÁÐÉÓÁÎÁ × %s"
+
+#: ../roundup/scripts/roundup_server.py:892
+msgid "Sorry, you can't run the server as a daemon on this Operating System"
+msgstr "éÚ×ÉÎÉÔÅ, × ÜÔÏÊ ÏÐÅÒÁÃÉÏÎÎÏÊ ÓÉÓÔÅÍÅ ÒÁÂÏÔÁ × ÆÏÎÏ×ÏÍ ÒÅÖÉÍÅ ÎÅ×ÏÚÍÏÖÎÁ"
+
+#: ../roundup/scripts/roundup_server.py:907
+#, 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-empty.html:6
+msgid "Please specify your search parameters!"
+msgstr "îÅ ÚÁÄÁÎÙ ÐÁÒÁÍÅÔÒÙ ÐÏÉÓËÁ."
+
+#: ../templates/classic/html/_generic.help-list.html:20
+#: ../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:35
+#: ../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:35
+#: ../templates/minimal/html/user.register.html:14
+msgid "You are not allowed to view this page."
+msgstr "õ ×ÁÓ ÎÅÔ ÒÁÚÒÅÛÅÎÉÑ ÎÁ ÐÒÏÓÍÏÔÒ ÜÔÏÊ ÓÔÒÁÎÉÃÙ."
+
+#: ../templates/classic/html/_generic.help-list.html:34
+msgid "1..25 out of 50"
+msgstr "1..25 ÉÚ 50"
+
+#: ../templates/classic/html/_generic.help-search.html:9
+msgid "Generic template ${template} or version for class ${classname} is not yet implemented"
+msgstr "îÅ ÎÁÊÄÅΠÏÂÝÉÊ ÛÁÂÌÏΠ${template} ÉÌÉ ÅÇÏ ×ÅÒÓÉÑ ÄÌÑ ËÌÁÓÓÁ ${classname}."
+
+#: ../templates/classic/html/_generic.help-submit.html:57
+#: ../templates/classic/html/_generic.help.html:31
+#: ../templates/minimal/html/_generic.help.html:31
+msgid " Cancel "
+msgstr " ïÔÍÅÎÉÔØ "
+
+#: ../templates/classic/html/_generic.help-submit.html:63
+#: ../templates/classic/html/_generic.help.html:34
+#: ../templates/minimal/html/_generic.help.html:34
+msgid " Apply "
+msgstr " ðÒÉÍÅÎÉÔØ "
+
+#: ../templates/classic/html/_generic.help.html:9
+#: ../templates/classic/html/user.help.html:13
+#: ../templates/minimal/html/_generic.help.html:9
+msgid "${property} help - ${tracker}"
+msgstr "óÐÒÁ×ËÁ ÐÏ ÐÏÌÀ \"${property}\" - ${tracker}"
+
+#: ../templates/classic/html/_generic.help.html:41
+#: ../templates/classic/html/help.html:21
+#: ../templates/classic/html/issue.index.html:80
+#: ../templates/minimal/html/_generic.help.html:41
+msgid "&lt;&lt; previous"
+msgstr "&lt;&lt; ÐÒÅÄÙÄÕÝÉÅ"
+
+#: ../templates/classic/html/_generic.help.html:53
+#: ../templates/classic/html/help.html:28
+#: ../templates/classic/html/issue.index.html:88
+#: ../templates/minimal/html/_generic.help.html:53
+msgid "${start}..${end} out of ${total}"
+msgstr "${start}..${end} ÉÚ ${total}"
+
+#: ../templates/classic/html/_generic.help.html:57
+#: ../templates/classic/html/help.html:32
+#: ../templates/classic/html/issue.index.html:91
+#: ../templates/minimal/html/_generic.help.html:57
+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:19
+#: ../templates/classic/html/_generic.item.html:16
+#: ../templates/classic/html/file.item.html:13
+#: ../templates/classic/html/issue.index.html:20
+#: ../templates/classic/html/issue.item.html:32
+#: ../templates/classic/html/msg.item.html:30
+#: ../templates/classic/html/user.index.html:13
+#: ../templates/classic/html/user.item.html:39
+#: ../templates/minimal/html/_generic.index.html:19
+#: ../templates/minimal/html/_generic.item.html:17
+#: ../templates/minimal/html/user.index.html:13
+#: ../templates/minimal/html/user.item.html:39
+#: ../templates/minimal/html/user.register.html:17
+msgid "Please login with your username and password."
+msgstr "õËÁÖÉÔÅ ÉÍÑ ÐÏÌØÚÏ×ÁÔÅÌÑ É ÐÁÒÏÌØ ÄÌÑ ×ÈÏÄÁ × ÓÉÓÔÅÍÕ."
+
+#: ../templates/classic/html/_generic.index.html:28
+#: ../templates/minimal/html/_generic.index.html:28
+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:50
+#: ../templates/minimal/html/_generic.index.html:50
+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:27
+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:48
+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:23
+#: ../templates/classic/html/user.register.html:17
+msgid "Name"
+msgstr "éÍÑ"
+
+#: ../templates/classic/html/file.item.html:45
+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
+#: ../templates/classic/html/issue.index.html:10
+msgid "List of issues"
+msgstr "óÐÉÓÏË ÚÁÄÁÞ"
+
+#: ../templates/classic/html/issue.index.html:27
+#: ../templates/classic/html/issue.item.html:49
+msgid "Priority"
+msgstr "ðÒÉÏÒÉÔÅÔ"
+
+#: ../templates/classic/html/issue.index.html:28
+msgid "ID"
+msgstr ""
+
+#: ../templates/classic/html/issue.index.html:29
+msgid "Creation"
+msgstr "äÁÔÁ ÓÏÚÄÁÎÉÑ"
+
+#: ../templates/classic/html/issue.index.html:30
+msgid "Activity"
+msgstr "äÅÊÓÔ×ÉÅ"
+
+#: ../templates/classic/html/issue.index.html:31
+msgid "Actor"
+msgstr "÷ÙÐÏÌÎÉÌ"
+
+#: ../templates/classic/html/issue.index.html:32
+#: ../templates/classic/html/keyword.item.html:37
+msgid "Keyword"
+msgstr "ëÌÀÞÅ×ÏÅ ÓÌÏ×Ï"
+
+#: ../templates/classic/html/issue.index.html:33
+#: ../templates/classic/html/issue.item.html:44
+msgid "Title"
+msgstr "úÁÇÌÁ×ÉÅ"
+
+#: ../templates/classic/html/issue.index.html:34
+#: ../templates/classic/html/issue.item.html:51
+msgid "Status"
+msgstr "óÔÁÔÕÓ"
+
+#: ../templates/classic/html/issue.index.html:35
+msgid "Creator"
+msgstr "á×ÔÏÒ"
+
+#: ../templates/classic/html/issue.index.html:36
+msgid "Assigned&nbsp;To"
+msgstr "éÓÐÏÌÎÉÔÅÌØ"
+
+#: ../templates/classic/html/issue.index.html:104
+msgid "Download as CSV"
+msgstr "óËÁÞÁÔØ CSV"
+
+#: ../templates/classic/html/issue.index.html:114
+msgid "Sort on:"
+msgstr "óÏÒÔÉÒÏ×ËÁ:"
+
+#: ../templates/classic/html/issue.index.html:118
+#: ../templates/classic/html/issue.index.html:139
+msgid "- nothing -"
+msgstr "- ÎÅÔ -"
+
+#: ../templates/classic/html/issue.index.html:126
+#: ../templates/classic/html/issue.index.html:147
+msgid "Descending:"
+msgstr "ðÏ ÕÂÙ×ÁÎÉÀ:"
+
+#: ../templates/classic/html/issue.index.html:135
+msgid "Group on:"
+msgstr "çÒÕÐÐÉÒÏ×ËÁ:"
+
+#: ../templates/classic/html/issue.index.html:154
+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:56
+msgid "Superseder"
+msgstr "úÁÍÅÝÅÎÉÅ"
+
+#: ../templates/classic/html/issue.item.html:61
+msgid "View:"
+msgstr "ðÒÏÓÍÏÔÒ:"
+
+#: ../templates/classic/html/issue.item.html:67
+msgid "Nosy List"
+msgstr "òÁÓÓÙÌËÁ ÉÚ×ÅÝÅÎÉÊ"
+
+#: ../templates/classic/html/issue.item.html:76
+msgid "Assigned To"
+msgstr "éÓÐÏÌÎÉÔÅÌØ"
+
+#: ../templates/classic/html/issue.item.html:78
+#: ../templates/classic/html/page.html:103
+#: ../templates/minimal/html/page.html:102
+msgid "Keywords"
+msgstr "ëÌÀÞÅ×ÙÅ&nbsp;ÓÌÏ×Á"
+
+#: ../templates/classic/html/issue.item.html:86
+msgid "Change Note"
+msgstr "úÁÍÅÔËÉ"
+
+#: ../templates/classic/html/issue.item.html:94
+msgid "File"
+msgstr "æÁÊÌ"
+
+#: ../templates/classic/html/issue.item.html:106
+msgid "Make a copy"
+msgstr "óËÏÐÉÒÏ×ÁÔØ"
+
+#: ../templates/classic/html/issue.item.html:114
+#: ../templates/classic/html/user.item.html:153
+#: ../templates/classic/html/user.register.html:69
+#: ../templates/minimal/html/user.item.html:153
+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:128
+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:132
+#: ../templates/classic/html/msg.item.html:61
+msgid "Files"
+msgstr "æÁÊÌÙ"
+
+#: ../templates/classic/html/issue.item.html:134
+#: ../templates/classic/html/msg.item.html:63
+msgid "File name"
+msgstr "éÍÑ ÆÁÊÌÁ"
+
+#: ../templates/classic/html/issue.item.html:135
+#: ../templates/classic/html/msg.item.html:64
+msgid "Uploaded"
+msgstr "úÁÇÒÕÖÅÎ"
+
+#: ../templates/classic/html/issue.item.html:136
+msgid "Type"
+msgstr "ôÉÐ"
+
+#: ../templates/classic/html/issue.item.html:137
+#: ../templates/classic/html/query.edit.html:30
+msgid "Edit"
+msgstr "òÅÄÁËÔÉÒÏ×ÁÔØ"
+
+#: ../templates/classic/html/issue.item.html:138
+msgid "Remove"
+msgstr "õÄÁÌÉÔØ"
+
+#: ../templates/classic/html/issue.item.html:158
+#: ../templates/classic/html/issue.item.html:179
+#: ../templates/classic/html/query.edit.html:50
+msgid "remove"
+msgstr "ÕÄÁÌÉÔØ"
+
+#: ../templates/classic/html/issue.item.html:165
+#: ../templates/classic/html/msg.index.html:9
+msgid "Messages"
+msgstr "óÏÏÂÝÅÎÉÑ"
+
+#: ../templates/classic/html/issue.item.html:169
+msgid "msg${id} (view)"
+msgstr "msg${id} (ÐÒÏÓÍÏÔÒ)"
+
+#: ../templates/classic/html/issue.item.html:170
+msgid "Author: ${author}"
+msgstr "á×ÔÏÒ: ${author}"
+
+#: ../templates/classic/html/issue.item.html:172
+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:31
+msgid "Filter on"
+msgstr "þÔÏ ÉÓËÁÔØ"
+
+#: ../templates/classic/html/issue.search.html:32
+msgid "Display"
+msgstr "ðÏËÁÚÁÔØ"
+
+#: ../templates/classic/html/issue.search.html:33
+msgid "Sort on"
+msgstr "óÏÒÔÉÒÏ×ÁÔØ"
+
+#: ../templates/classic/html/issue.search.html:34
+msgid "Group on"
+msgstr "çÒÕÐÐÉÒÏ×ÁÔØ"
+
+#: ../templates/classic/html/issue.search.html:38
+msgid "All text*:"
+msgstr "×Ï ×ÓÅÍ ÔÅËÓÔÅ*:"
+
+#: ../templates/classic/html/issue.search.html:46
+msgid "Title:"
+msgstr "× ÚÁÇÏÌÏ×ËÅ:"
+
+#: ../templates/classic/html/issue.search.html:56
+msgid "Keyword:"
+msgstr "ëÌÀÞÅ×ÏÅ ÓÌÏ×Ï:"
+
+#: ../templates/classic/html/issue.search.html:58
+#: ../templates/classic/html/issue.search.html:123
+#: ../templates/classic/html/issue.search.html:139
+msgid "not selected"
+msgstr "ÎÅ ÕÓÔÁÎÏ×ÌÅÎ"
+
+#: ../templates/classic/html/issue.search.html:67
+msgid "ID:"
+msgstr ""
+
+#: ../templates/classic/html/issue.search.html:75
+msgid "Creation Date:"
+msgstr "äÁÔÁ ÓÏÚÄÁÎÉÑ:"
+
+#: ../templates/classic/html/issue.search.html:86
+msgid "Creator:"
+msgstr "á×ÔÏÒ:"
+
+#: ../templates/classic/html/issue.search.html:88
+msgid "created by me"
+msgstr "ÓÏÚÄÁÎÏ ÍÎÏÊ"
+
+#: ../templates/classic/html/issue.search.html:97
+msgid "Activity:"
+msgstr "äÅÊÓÔ×ÉÅ:"
+
+#: ../templates/classic/html/issue.search.html:108
+msgid "Actor:"
+msgstr "÷ÙÐÏÌÎÉÌ:"
+
+#: ../templates/classic/html/issue.search.html:110
+msgid "done by me"
+msgstr "×ÙÐÏÌÎÅÎÏ ÍÎÏÊ"
+
+#: ../templates/classic/html/issue.search.html:121
+msgid "Priority:"
+msgstr "ðÒÉÏÒÉÔÅÔ:"
+
+#: ../templates/classic/html/issue.search.html:134
+msgid "Status:"
+msgstr "óÔÁÔÕÓ:"
+
+#: ../templates/classic/html/issue.search.html:137
+msgid "not resolved"
+msgstr "ÎÅ ÚÁËÒÙÔ"
+
+#: ../templates/classic/html/issue.search.html:152
+msgid "Assigned to:"
+msgstr "éÓÐÏÌÎÉÔÅÌØ:"
+
+#: ../templates/classic/html/issue.search.html:155
+msgid "assigned to me"
+msgstr "ÐÏÒÕÞÅÎÏ ÍÎÅ"
+
+#: ../templates/classic/html/issue.search.html:157
+msgid "unassigned"
+msgstr "ÎÅÎÁÚÎÁÞÅÎÏ"
+
+#: ../templates/classic/html/issue.search.html:167
+msgid "No Sort or group:"
+msgstr "îÅ ÓÏÒÔÉÒÏ×ÁÔØ / ÎÅ ÇÒÕÐÐÉÒÏ×ÁÔØ"
+
+#: ../templates/classic/html/issue.search.html:175
+msgid "Pagesize:"
+msgstr "òÁÚÍÅÒ ÓÔÒÁÎÉÃÙ:"
+
+#: ../templates/classic/html/issue.search.html:181
+msgid "Start With:"
+msgstr "îÁÞÁÔØ Ó:"
+
+#: ../templates/classic/html/issue.search.html:187
+msgid "Sort Descending:"
+msgstr "óÏÒÔÉÒÏ×ÁÔØ ÐÏ ÕÂÙ×ÁÎÉÀ:"
+
+#: ../templates/classic/html/issue.search.html:194
+msgid "Group Descending:"
+msgstr "çÒÕÐÐÉÒÏ×ÁÔØ ÐÏ ÕÂÙ×ÁÎÉÀ"
+
+#: ../templates/classic/html/issue.search.html:201
+msgid "Query name**:"
+msgstr "éÍÑ ÚÁÐÒÏÓÁ**:"
+
+#: ../templates/classic/html/issue.search.html:213
+#: ../templates/classic/html/page.html:43
+#: ../templates/classic/html/page.html:92
+#: ../templates/classic/html/user.help-search.html:69
+#: ../templates/minimal/html/page.html:43
+#: ../templates/minimal/html/page.html:91
+msgid "Search"
+msgstr "ðÏÉÓË"
+
+#: ../templates/classic/html/issue.search.html:218
+msgid "*: The \"all text\" field will look in message bodies and issue titles"
+msgstr "*: ðÏÉÓË ÐÏ ×ÓÅÍÕ ÔÅËÓÔÕ ÉÝÅÔ ××ÅÄÅÎÎÕÀ ÓÔÒÏËÕ × ÚÁÇÏÌÏ×ËÁÈ É × ÔÅÌÅ ÓÏÏÂÝÅÎÉÊ."
+
+#: ../templates/classic/html/issue.search.html:221
+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/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:38
+msgid "Author"
+msgstr "á×ÔÏÒ"
+
+#: ../templates/classic/html/msg.item.html:43
+msgid "Recipients"
+msgstr "áÄÒÅÓÁÔÙ"
+
+#: ../templates/classic/html/msg.item.html:54
+msgid "Content"
+msgstr "óÏÄÅÒÖÁÎÉÅ"
+
+#: ../templates/classic/html/page.html:54
+#: ../templates/minimal/html/page.html:53
+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:65
+#: ../templates/minimal/html/page.html:64
+msgid "Issues"
+msgstr "úÁÄÁÞÉ"
+
+#: ../templates/classic/html/page.html:67
+#: ../templates/classic/html/page.html:105
+#: ../templates/minimal/html/page.html:66
+#: ../templates/minimal/html/page.html:104
+msgid "Create New"
+msgstr "äÏÂÁ×ÉÔØ"
+
+#: ../templates/classic/html/page.html:69
+#: ../templates/minimal/html/page.html:68
+msgid "Show Unassigned"
+msgstr "îÅÎÁÚÎÁÞÅÎÎÙÅ"
+
+#: ../templates/classic/html/page.html:81
+#: ../templates/minimal/html/page.html:80
+msgid "Show All"
+msgstr "ðÏËÁÚÁÔØ ×ÓÅ"
+
+#: ../templates/classic/html/page.html:93
+#: ../templates/minimal/html/page.html:92
+msgid "Show issue:"
+msgstr "ðÏËÁÚÁÔØ:"
+
+#: ../templates/classic/html/page.html:108
+#: ../templates/minimal/html/page.html:107
+msgid "Edit Existing"
+msgstr "òÅÄÁËÔÉÒÏ×ÁÔØ"
+
+#: ../templates/classic/html/page.html:114
+#: ../templates/minimal/html/page.html:113
+msgid "Administration"
+msgstr "áÄÍÉÎÉÓÔÒÉÒÏ×ÁÎÉÅ"
+
+#: ../templates/classic/html/page.html:116
+#: ../templates/minimal/html/page.html:115
+msgid "Class List"
+msgstr "óÐÉÓÏË ËÌÁÓÓÏ×"
+
+#: ../templates/classic/html/page.html:120
+#: ../templates/minimal/html/page.html:119
+msgid "User List"
+msgstr "óÐÉÓÏË ÐÏÌØÚÏ×ÁÔÅÌÅÊ"
+
+#: ../templates/classic/html/page.html:122
+#: ../templates/minimal/html/page.html:121
+msgid "Add User"
+msgstr "äÏÂÁ×ÉÔØ ÐÏÌØÚÏ×ÁÔÅÌÑ"
+
+#: ../templates/classic/html/page.html:129
+#: ../templates/classic/html/page.html:135
+#: ../templates/minimal/html/page.html:128
+#: ../templates/minimal/html/page.html:134
+msgid "Login"
+msgstr "÷ÈÏÄ"
+
+#: ../templates/classic/html/page.html:134
+#: ../templates/minimal/html/page.html:133
+msgid "Remember me?"
+msgstr "úÁÐÏÍÎÉÔØ"
+
+#: ../templates/classic/html/page.html:138
+#: ../templates/classic/html/user.register.html:63
+#: ../templates/minimal/html/page.html:137
+#: ../templates/minimal/html/user.register.html:61
+msgid "Register"
+msgstr "úÁÒÅÇÉÓÔÒÉÒÏ×ÁÔØÓÑ"
+
+#: ../templates/classic/html/page.html:141
+#: ../templates/minimal/html/page.html:140
+msgid "Lost&nbsp;your&nbsp;login?"
+msgstr "úÁÂÙÌÉ&nbsp;ÐÁÒÏÌØ?"
+
+#: ../templates/classic/html/page.html:146
+#: ../templates/minimal/html/page.html:145
+msgid "Hello, ${user}"
+msgstr "úÄÒÁ×ÓÔ×ÕÊÔÅ, ${user}!"
+
+#: ../templates/classic/html/page.html:148
+msgid "Your Issues"
+msgstr "úÁÄÁÞÉ"
+
+#: ../templates/classic/html/page.html:160
+#: ../templates/minimal/html/page.html:147
+msgid "Your Details"
+msgstr "õÞÅÔÎÁÑ ËÁÒÔÏÞËÁ"
+
+#: ../templates/classic/html/page.html:162
+#: ../templates/minimal/html/page.html:149
+msgid "Logout"
+msgstr "÷ÙÈÏÄ"
+
+#: ../templates/classic/html/page.html:166
+#: ../templates/minimal/html/page.html:153
+msgid "Help"
+msgstr "ðÏÍÏÝØ"
+
+#: ../templates/classic/html/page.html:167
+#: ../templates/minimal/html/page.html:154
+msgid "Roundup docs"
+msgstr "äÏËÕÍÅÎÔÁÃÉÑ Roundup"
+
+#: ../templates/classic/html/page.html:177
+#: ../templates/minimal/html/page.html:164
+msgid "clear this message"
+msgstr "ÓÂÒÏÓÉÔØ ÜÔÏ ÓÏÏÂÝÅÎÉÅ"
+
+#: ../templates/classic/html/page.html:241
+#: ../templates/classic/html/page.html:256
+#: ../templates/classic/html/page.html:270
+#: ../templates/minimal/html/page.html:228
+#: ../templates/minimal/html/page.html:243
+#: ../templates/minimal/html/page.html:257
+msgid "don't care"
+msgstr "ÎÅ×ÁÖÎÏ"
+
+#: ../templates/classic/html/page.html:243
+#: ../templates/classic/html/page.html:258
+#: ../templates/classic/html/page.html:271
+#: ../templates/minimal/html/page.html:230
+#: ../templates/minimal/html/page.html:245
+#: ../templates/minimal/html/page.html:258
+msgid "------------"
+msgstr ""
+
+#: ../templates/classic/html/page.html:299
+#: ../templates/minimal/html/page.html:286
+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.help-search.html:73
+msgid "Pagesize"
+msgstr "òÁÚÍÅÒ ÓÔÒÁÎÉÃÙ"
+
+#: ../templates/classic/html/user.help.html:43
+msgid "Your browser is not capable of using frames; you should be redirected immediately, or visit ${link}."
+msgstr "÷ÁÛ ÂÒÁÕÚÅÒ ÎÅ ÐÏÄÄÅÒÖÉ×ÁÅÔ ÆÒÅÊÍÙ; ÐÅÒÅÊÄÉÔÅ ÎÁ ÓÔÒÁÎÉÃÕ ${link}."
+
+#: ../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:19
+#: ../templates/minimal/html/user.index.html:19
+msgid "Username"
+msgstr "ðÏÌØÚÏ×ÁÔÅÌØ"
+
+#: ../templates/classic/html/user.index.html:20
+msgid "Real name"
+msgstr "éÍÑ, ÆÁÍÉÌÉÑ"
+
+#: ../templates/classic/html/user.index.html:21
+#: ../templates/classic/html/user.register.html:45
+msgid "Organisation"
+msgstr "ïÒÇÁÎÉÚÁÃÉÑ"
+
+#: ../templates/classic/html/user.index.html:22
+#: ../templates/minimal/html/user.index.html:20
+msgid "Email address"
+msgstr "áÄÒÅÓ email"
+
+#: ../templates/classic/html/user.index.html:23
+msgid "Phone number"
+msgstr "ôÅÌÅÆÏÎ"
+
+#: ../templates/classic/html/user.index.html:24
+msgid "Retire"
+msgstr "õ×ÏÌÉÔØ"
+
+#: ../templates/classic/html/user.index.html:37
+msgid "retire"
+msgstr "Õ×ÏÌÉÔØ"
+
+#: ../templates/classic/html/user.item.html:9
+#: ../templates/minimal/html/user.item.html:9
+msgid "User ${id}: ${title} - ${tracker}"
+msgstr "ðÏÌØÚÏ×ÁÔÅÌØ ${id}: ${title} - ${tracker}"
+
+#: ../templates/classic/html/user.item.html:12
+#: ../templates/minimal/html/user.item.html:12
+msgid "New User - ${tracker}"
+msgstr "îÏ×ÙÊ ÐÏÌØÚÏ×ÁÔÅÌØ - ${tracker}"
+
+#: ../templates/classic/html/user.item.html:21
+#: ../templates/minimal/html/user.item.html:21
+msgid "New User"
+msgstr "îÏ×ÙÊ ÐÏÌØÚÏ×ÁÔÅÌØ"
+
+#: ../templates/classic/html/user.item.html:23
+#: ../templates/minimal/html/user.item.html:23
+msgid "New User Editing"
+msgstr "òÅÇÉÓÔÒÁÃÉÑ ÎÏ×ÏÇÏ ÐÏÌØÚÏ×ÁÔÅÌÑ"
+
+#: ../templates/classic/html/user.item.html:26
+#: ../templates/minimal/html/user.item.html:26
+msgid "User${id}"
+msgstr "ðÏÌØÚÏ×ÁÔÅÌØ ${id}"
+
+#: ../templates/classic/html/user.item.html:29
+#: ../templates/minimal/html/user.item.html:29
+msgid "User${id} Editing"
+msgstr "òÅÄÁËÔÉÒÏ×ÁÎÉÅ ËÁÒÔÏÞËÉ ÐÏÌØÚÏ×ÁÔÅÌÑ ${id}"
+
+#: ../templates/classic/html/user.item.html:80
+#: ../templates/classic/html/user.register.html:33
+#: ../templates/minimal/html/user.item.html:80
+#: ../templates/minimal/html/user.register.html:41
+msgid "Roles"
+msgstr "òÏÌÉ"
+
+#: ../templates/classic/html/user.item.html:88
+#: ../templates/minimal/html/user.item.html:88
+msgid "(to give the user more than one role, enter a comma,separated,list)"
+msgstr "(ÅÓÌÉ ÒÏÌÅÊ ÎÅÓËÏÌØËÏ, ÐÅÒÅÞÉÓÌÉÔÅ ÉÈ ÞÅÒÅÚ ÚÁÐÑÔÕÀ)"
+
+#: ../templates/classic/html/user.item.html:109
+#: ../templates/minimal/html/user.item.html:109
+msgid "(this is a numeric hour offset, the default is ${zone})"
+msgstr "(ÞÉÓÌÏ - ÒÁÚÎÉÃÁ ÍÅÖÄÕ ÍÅÓÔÎÙÍ É ÇÒÉÎ×ÉÞÓËÉÍ ×ÒÅÍÅÎÅÍ, ÐÏ ÕÍÏÌÞÁÎÉÀ - ${zone})"
+
+#: ../templates/classic/html/user.item.html:130
+#: ../templates/classic/html/user.register.html:53
+#: ../templates/minimal/html/user.item.html:130
+#: ../templates/minimal/html/user.register.html:53
+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.register.html:21
+#: ../templates/minimal/html/user.register.html:29
+msgid "Login Name"
+msgstr "õÞÅÔÎÏÅ ÉÍÑ"
+
+#: ../templates/classic/html/user.register.html:25
+#: ../templates/minimal/html/user.register.html:33
+msgid "Login Password"
+msgstr "ðÁÒÏÌØ"
+
+#: ../templates/classic/html/user.register.html:29
+#: ../templates/minimal/html/user.register.html:37
+msgid "Confirm Password"
+msgstr "(ÅÝÅ ÒÁÚ)"
+
+#: ../templates/classic/html/user.register.html:41
+msgid "Phone"
+msgstr "ôÅÌÅÆÏÎ"
+
+#: ../templates/classic/html/user.register.html:49
+#: ../templates/minimal/html/user.register.html:49
+msgid "E-mail address"
+msgstr "áÄÒÅÓ email"
+
+#: ../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/classic/initial_data.py:5
+msgid "critical"
+msgstr "ËÒÉÔÉÞÅÓËÉÊ"
+
+#: ../templates/classic/initial_data.py:6
+msgid "urgent"
+msgstr "ÓÒÏÞÎÙÊ"
+
+#: ../templates/classic/initial_data.py:7
+msgid "bug"
+msgstr "ÏÛÉÂËÁ"
+
+#: ../templates/classic/initial_data.py:8
+msgid "feature"
+msgstr "ÒÁÚ×ÉÔÉÅ"
+
+#: ../templates/classic/initial_data.py:9
+msgid "wish"
+msgstr "ÐÏÖÅÌÁÎÉÅ"
+
+#: ../templates/classic/initial_data.py:12
+msgid "unread"
+msgstr "ÎÏ×ÙÊ"
+
+#: ../templates/classic/initial_data.py:13
+msgid "deferred"
+msgstr "ÏÔÌÏÖÅÎ"
+
+#: ../templates/classic/initial_data.py:14
+msgid "chatting"
+msgstr "ÏÂÓÕÖÄÅÎÉÅ"
+
+#: ../templates/classic/initial_data.py:15
+msgid "need-eg"
+msgstr "ÎÕÖÅΠÐÒÉÍÅÒ"
+
+#: ../templates/classic/initial_data.py:16
+msgid "in-progress"
+msgstr "× ÒÁÂÏÔÅ"
+
+#: ../templates/classic/initial_data.py:17
+msgid "testing"
+msgstr "ÔÅÓÔÉÒÏ×ÁÎÉÅ"
+
+#: ../templates/classic/initial_data.py:18
+msgid "done-cbb"
+msgstr "ÓÄÅÌÁÎÏ; ÍÏÖÎÏ ÕÌÕÞÛÉÔØ"
+
+#: ../templates/classic/initial_data.py:19
+msgid "resolved"
+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 "ðÏÖÁÌÕÊÓÔÁ, ×ÈÏÄÉÔÅ ÉÌÉ ÚÁÒÅÇÉÓÔÒÉÒÕÊÔÅÓØ"
+
diff --git a/locale/zh_CN.po b/locale/zh_CN.po
new file mode 100644 (file)
index 0000000..32f7d2b
--- /dev/null
@@ -0,0 +1,2743 @@
+# Chinese message file for Roundup Issue Tracker
+# limodou <limodou@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@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@gmail.com>\n"
+"Language-Team: Chinese Simplified <limodou@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@server\n"
+" The username and password may be omitted:\n"
+"    pop username@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@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@server\n"
+" It also allows you to specify a specific mailbox other than INBOX using\n"
+" this format:\n"
+"    imap username:password@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@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@server\n"
+" 用户名和口令可以被省略:\n"
+"    pop username@server\n"
+"    pop server\n"
+" 都是有效的。如果没有提供用户名或口令都将在命令行被提示。\n"
+"\n"
+"APOP:\n"
+" 同 POP,但使用认证的 POP:\n"
+"    apop username:password@server\n"
+"\n"
+"IMAP:\n"
+" 联接到 IMAP 服务器。它支持同 POP 邮件相同的写法。\n"
+"    imap username:password@server\n"
+" 除了 INBOX 外还允许你指定一个特别的邮箱,\n"
+" 使用这种格式:    imap username:password@server mailbox\n"
+"\n"
+"IMAPS:\n"
+" 通过ssl联接到 IMAP 服务器。\n"
+" 它支持同 IMAP 一样的写法。\n"
+"    imaps username:password@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}"
+
diff --git a/locale/zh_TW.po b/locale/zh_TW.po
new file mode 100755 (executable)
index 0000000..9d76acf
--- /dev/null
@@ -0,0 +1,2743 @@
+# Chinese Traditional message file for Roundup Issue Tracker
+# Fred Lin <gasolin@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@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@gmail>\n"
+"Language-Team: Chinese Traditional <gasolin@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@server\n"
+" The username and password may be omitted:\n"
+"    pop username@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@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@server\n"
+" It also allows you to specify a specific mailbox other than INBOX using\n"
+" this format:\n"
+"    imap username:password@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@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@server\n"
+" 用戶名和口令可以被省略:\n"
+"    pop username@server\n"
+"    pop server\n"
+" 都是有效的。如果沒有提供用戶名或口令都將在命令行被提示。\n"
+"\n"
+"APOP:\n"
+" 同 POP,但使用認證的 POP:\n"
+"    apop username:password@server\n"
+"\n"
+"IMAP:\n"
+" 聯接到 IMAP 服務器。它支持同 POP 郵件相同的寫法。\n"
+"    imap username:password@server\n"
+" 除了 INBOX 外還允許你指定一個特別的郵箱,\n"
+" 使用這種格式:    imap username:password@server mailbox\n"
+"\n"
+"IMAPS:\n"
+" 通過ssl聯接到 IMAP 服務器。\n"
+" 它支持同 IMAP 一樣的寫法。\n"
+"    imaps username:password@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}"
+
index bd0eac0c25c887dd563771085703afd777286db5..91990c1d9a58ffb4d3116e885f893f12be7601dd 100644 (file)
@@ -14,8 +14,8 @@
 # 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.28 2004-03-26 06:38:48 richard Exp $
+#
+# $Id: __init__.py,v 1.54 2008-09-01 01:58:32 richard Exp $
 
 '''Roundup - issue tracking for knowledge workers.
 
@@ -27,14 +27,14 @@ Roundup manages a number of issues (with properties such as
 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. 
+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   | 
+ |   E-mail User    |    Web User     |      Detector        |   Command   |
  |-------------------------------------------------------------------------|
  |                         Roundup Database Layer                          |
  |-------------------------------------------------------------------------|
@@ -68,6 +68,6 @@ much prettier cake :)
 '''
 __docformat__ = 'restructuredtext'
 
-__version__ = '0.7.0b2'
+__version__ = '1.4.6'
 
 # vim: set filetype=python ts=4 sw=4 et si
index a2e7e7864d64689db8f06b9b6b331595843700ba..51695e2bd46b44789f4bb88ba23532d631070ab5 100644 (file)
 # 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.68 2004-04-17 01:47:37 richard Exp $
+#
+# $Id: admin.py,v 1.110 2008-02-07 03:28:33 richard Exp $
 
 '''Administration commands for maintaining Roundup trackers.
 '''
 __docformat__ = 'restructuredtext'
 
-import sys, os, getpass, getopt, re, UserDict, shutil, rfc822
-from roundup import date, hyperdb, roundupdb, init, password, token, rcsv
+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):
@@ -73,6 +75,7 @@ class AdminTool:
                 self.help[k[5:]] = getattr(self, k)
         self.tracker_home = ''
         self.db = None
+        self.db_uncommitted = False
 
     def get_class(self, classname):
         '''Get the class - raise an exception if it doesn't exist.
@@ -119,6 +122,8 @@ Options:
  -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.
 
@@ -131,16 +136,17 @@ Help:
         self.help_commands()
 
     def help_commands(self):
-        ''' List the commands available with their precis help.
+        ''' List the commands available with their help summary.
         '''
         print _('Commands:'),
         commands = ['']
         for command in self.commands.values():
-            h = command.__doc__.split('\n')[0]
+            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'))
-        commands.append(_('command, e.g. l == li == lis == list.'))
+        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
 
@@ -152,13 +158,13 @@ Help:
             return cmp(a.__name__, b.__name__)
         commands.sort(sortfun)
         for command in commands:
-            h = command.__doc__.split('\n')
+            h = _(command.__doc__).split('\n')
             name = command.__name__[3:]
             usage = h[0]
-            print _('''
+            print '''
 <tr><td valign=top><strong>%(name)s</strong></td>
     <td><tt>%(usage)s</tt><p>
-<pre>''')%locals()
+<pre>''' % locals()
             indent = indent_re.match(h[3])
             if indent: indent = len(indent.group(1))
             for line in h[3:]:
@@ -166,57 +172,58 @@ Help:
                     print line[indent:]
                 else:
                     print line
-            print _('</pre></td></tr>\n')
+            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".
+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.
+ . 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.
+ . 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
-valuu must contain a quote character, it must be backslash-quoted or inside
+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)
+           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)
+           \\\\               (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. 
+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. 
+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. 
+command-line.
 
 Date format examples:
   "2000-04-17.03:45" means <Date 2000-04-17.08:45:00>
@@ -232,11 +239,11 @@ Command help:
 ''')
         for name, command in self.commands.items():
             print _('%s:')%name
-            print _('   '), command.__doc__
+            print '   ', _(command.__doc__)
 
     def do_help(self, args, nl_re=re.compile('[\r\n]'),
             indent_re=re.compile(r'^(\s+)\S+')):
-        '''Usage: help topic
+        ""'''Usage: help topic
         Give help about topic.
 
         commands  -- list commands
@@ -248,7 +255,7 @@ Command help:
             topic = args[0]
         else:
             topic = 'help'
+
 
         # try help_ methods
         if self.help.has_key(topic):
@@ -264,7 +271,7 @@ Command help:
 
         # display the help for each match, removing the docsring indent
         for name, help in l:
-            lines = nl_re.split(help.__doc__)
+            lines = nl_re.split(_(help.__doc__))
             print lines[0]
             indent = indent_re.match(lines[1])
             if indent: indent = len(indent.group(1))
@@ -280,26 +287,31 @@ Command help:
 
         Look in the following places, where the later rules take precedence:
 
-         1. <prefix>/share/roundup/templates/*
+         1. <roundup.admin.__file__>/../../share/roundup/templates/*
+            this is where they will be if we installed an egg via easy_install
+         2. <prefix>/share/roundup/templates/*
             this should be the standard place to find them when Roundup is
             installed
-         2. <roundup.admin.__file__>/../templates/*
+         3. <roundup.admin.__file__>/../templates/*
             this will be used if Roundup's run in the distro (aka. source)
             directory
-         3. <current working dir>/*
+         4. <current working dir>/*
             this is for when someone unpacks a 3rd-party template
-         4. <current working dir>
+         5. <current working dir>
             this is for someone who "cd"s to the 3rd-party template dir
         '''
         # OK, try <prefix>/share/roundup/templates
+        #     and <egg-directory>/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
+        #    /usr/lib/python2.5/site-packages/roundup/admin.py  (5 dirs up)
+        #    c:\python25\lib\site-packages\roundup\admin.py     (4 dirs up)
+        #    /usr/lib/python2.5/site-packages/roundup-1.3.3-py2.5-egg/roundup/admin.py
+        #    (2 dirs up)
+        #
+        # we're interested in where the directory containing "share" is
         templates = {}
-        for N in 4, 5:
+        for N in 2, 4, 5:
             path = __file__
             # move up N elements in the path
             for i in range(N):
@@ -333,17 +345,24 @@ Command help:
         templates = self.listTemplates()
         print _('Templates:'), ', '.join(templates.keys())
         import roundup.backends
-        backends = roundup.backends.__all__
+        backends = roundup.backends.list_backends()
         print _('Back ends:'), ', '.join(backends)
 
     def do_install(self, tracker_home, args):
-        '''Usage: install [template [backend [admin password]]]
+        ""'''Usage: install [template [backend [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 command will prompt for the tracker home directory
+        (if not supplied through TRACKER_HOME or the -i option).
+        The template and backend may be specified on the command-line
+        as arguments, in that order.
+
+        Command line arguments following the backend allows you 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
@@ -362,11 +381,14 @@ Command help:
             raise UsageError, _('Instance home parent directory "%(parent)s"'
                 ' does not exist')%locals()
 
-        if os.path.exists(os.path.join(tracker_home, 'config.py')):
-            print _('WARNING: There appears to be a tracker in '
-                '"%(tracker_home)s"!')%locals()
-            print _('If you re-install it, you will lose all the data!')
-            ok = raw_input(_('Erase it? Y/N: ')).strip()
+        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
 
@@ -385,7 +407,7 @@ Command help:
 
         # select hyperdb backend
         import roundup.backends
-        backends = roundup.backends.__all__
+        backends = roundup.backends.list_backends()
         backend = len(args) > 2 and args[2] or ''
         if backend not in backends:
             print _('Back ends:'), ', '.join(backends)
@@ -395,29 +417,64 @@ Command help:
                 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'])
+        init.install(tracker_home, templates[template]['path'], settings=defns)
         init.write_select_db(tracker_home, backend)
 
-        print _('''
+        print _("""
+---------------------------------------------------------------------------
  You should now edit the tracker configuration file:
-   %(config_file)s
- ... at a minimum, you must set MAILHOST, TRACKER_WEB, MAIL_DOMAIN and
- ADMIN_EMAIL.
-
- If you wish to modify the default schema, you should also edit the database
- initialisation 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.
-''')%{
-    'config_file': os.path.join(tracker_home, 'config.py'),
-    'database_config_file': os.path.join(tracker_home, 'dbinit.py')
+
+ 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]
+        ""'''Usage: initialise [adminpw]
         Initialise a new Roundup tracker.
 
         The administrator details will be set at this step.
@@ -443,37 +500,34 @@ Command help:
             raise UsageError, _('Instance has not been installed')%locals()
 
         # is there already a database?
-        try:
-            db_exists = tracker.select_db.Database.exists(tracker.config)
-        except AttributeError:
-            # TODO: move this code to exists() static method in every backend
-            db_exists = os.path.exists(os.path.join(tracker_home, 'db'))
-        if db_exists:
-            print _('WARNING: The database is already initialised!')
-            print _('If you re-initialise it, you will lose all the data!')
-            ok = raw_input(_('Erase it? Y/N: ')).strip()
+        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
 
-            # Get a database backend in use by tracker
-            try:
-                # nuke it
-                tracker.select_db.Database.nuke(tracker.config)
-            except AttributeError:
-                # TODO: move this code to nuke() static method in every backend
-                shutil.rmtree(os.path.join(tracker_home, 'db'))
+            backend = tracker.get_backend_name()
+
+            # nuke it
+            tracker.nuke()
+
+            # re-write the backend select file
+            init.write_select_db(tracker_home, backend)
 
         # GO
-        init.initialise(tracker_home, adminpw)
+        tracker.init(password.Password(adminpw))
 
         return 0
 
 
     def do_get(self, args):
-        '''Usage: get property designator[,designator]*
+        ""'''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.
+        Retrieves the property value of the nodes specified
+        by the designators.
         '''
         if len(args) < 2:
             raise UsageError, _('Not enough arguments supplied')
@@ -542,17 +596,17 @@ Command help:
         return 0
 
 
-    def do_set(self, args, pwre = re.compile(r'{(\w+)}(.+)')):
-        '''Usage: set items property=value property=value ...
+    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").
+        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')
@@ -594,14 +648,16 @@ Command help:
             except (TypeError, IndexError, ValueError), message:
                 import traceback; traceback.print_exc()
                 raise UsageError, message
+        self.db_uncommitted = True
         return 0
 
     def do_find(self, args):
-        '''Usage: find classname propname=value ...
+        ""'''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.
+        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')
@@ -612,34 +668,22 @@ Command help:
         # handle the propname=value argument
         props = self.props_from_args(args[1:])
 
-        # if the value isn't a number, look up the linked class to get the
-        # number
+        # convert the user-input value to a value used for find()
         for propname, value in props.items():
-            num_re = re.compile('^\d+$')
-            if value == '-1':
-                props[propname] = None
-            elif not num_re.match(value):
-                # get the property
-                try:
-                    property = cl.properties[propname]
-                except KeyError:
-                    raise UsageError, _('%(classname)s has no property '
-                        '"%(propname)s"')%locals()
-
-                # make sure it's a link
-                if (not isinstance(property, hyperdb.Link) and not
-                        isinstance(property, hyperdb.Multilink)):
-                    raise UsageError, _('You may only "find" link properties')
-
-                # get the linked-to class and look up the key property
-                link_class = self.db.getclass(property.classname)
-                try:
-                    props[propname] = link_class.lookup(value)
-                except TypeError:
-                    raise UsageError, _('%(classname)s has no key property"')%{
-                        'classname': link_class.classname}
+            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 
+        # now do the find
         try:
             id = []
             designator = []
@@ -668,7 +712,7 @@ Command help:
         return 0
 
     def do_specification(self, args):
-        '''Usage: specification classname
+        ""'''Usage: specification classname
         Show the properties for a classname.
 
         This lists the properties for a given class.
@@ -688,7 +732,7 @@ Command help:
                 print _('%(key)s: %(value)s')%locals()
 
     def do_display(self, args):
-        '''Usage: display designator[,designator]*
+        ""'''Usage: display designator[,designator]*
         Show the property values for the given node(s).
 
         This lists the properties and their associated values for the given
@@ -714,8 +758,8 @@ Command help:
                 value = cl.get(nodeid, key)
                 print _('%(key)s: %(value)s')%locals()
 
-    def do_create(self, args, pwre = re.compile(r'{(\w+)}(.+)')):
-        '''Usage: create classname property=value ...
+    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
@@ -776,20 +820,21 @@ Command help:
             print apply(cl.create, (), props)
         except (TypeError, IndexError, ValueError), message:
             raise UsageError, message
+        self.db_uncommitted = True
         return 0
 
     def do_list(self, args):
-        '''Usage: list classname [property]
+        ""'''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.
+        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.
+        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')
@@ -832,20 +877,21 @@ Command help:
         return 0
 
     def do_table(self, args):
-        '''Usage: table classname [property[,property]*]
+        ""'''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::
+        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   
+          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::
@@ -853,7 +899,7 @@ Command help:
           roundup> table priority id,name:
           Id Name
           1  fata
-          2  bug       
+          2  bug
           3  usab
           4  feat
 
@@ -901,7 +947,7 @@ Command help:
                    if curlen > maxlen:
                        maxlen = curlen
                props.append((spec, maxlen))
-               
+
         # now display the heading
         print ' '.join([name.capitalize().ljust(width) for name,width in props])
 
@@ -925,7 +971,7 @@ Command help:
         return 0
 
     def do_history(self, args):
-        '''Usage: history designator
+        ""'''Usage: history designator
         Show the history entries of a designator.
 
         Lists the journal entries for the node identified by the designator.
@@ -946,7 +992,7 @@ Command help:
         return 0
 
     def do_commit(self, args):
-        '''Usage: commit
+        ""'''Usage: commit
         Commit changes made to the database during an interactive session.
 
         The changes made during an interactive session are not
@@ -957,10 +1003,11 @@ Command help:
         they are successful.
         '''
         self.db.commit()
+        self.db_uncommitted = False
         return 0
 
     def do_rollback(self, args):
-        '''Usage: rollback
+        ""'''Usage: rollback
         Undo all changes that are pending commit to the database.
 
         The changes made during an interactive session are not
@@ -969,14 +1016,15 @@ Command help:
         immediately after would make no changes to the database.
         '''
         self.db.rollback()
+        self.db_uncommitted = False
         return 0
 
     def do_retire(self, args):
-        '''Usage: retire designator[,designator]*
+        ""'''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.
+        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')
@@ -992,10 +1040,11 @@ Command help:
                 raise UsageError, _('no such class "%(classname)s"')%locals()
             except IndexError:
                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
+        self.db_uncommitted = True
         return 0
 
     def do_restore(self, args):
-        '''Usage: restore designator[,designator]*
+        ""'''Usage: restore designator[,designator]*
         Restore the retired node specified by designator.
 
         The given nodes will become available for users again.
@@ -1014,13 +1063,17 @@ Command help:
                 raise UsageError, _('no such class "%(classname)s"')%locals()
             except IndexError:
                 raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals()
+        self.db_uncommitted = True
         return 0
 
-    def do_export(self, args):
-        '''Usage: export [class[,class]] export_dir
+    def do_export(self, args, export_files=True):
+        ""'''Usage: export [[-]class[,class]] export_dir
         Export the database to colon-separated-value files.
+        To exclude the files (e.g. for the msg or file class),
+        use the exporttables command.
 
-        Optionally limit the export to just the names classes.
+        Optionally limit the export to just the named classes
+        or exclude the named classes, if the 1st argument starts with '-'.
 
         This action exports the current data from the database into
         colon-separated-value files that are placed in the nominated
@@ -1029,49 +1082,84 @@ Command help:
         # grab the directory to export to
         if len(args) < 1:
             raise UsageError, _('Not enough arguments supplied')
-        if rcsv.error:
-            raise UsageError, _(rcsv.error)
 
         dir = args[-1]
 
         # get the list of classes to export
         if len(args) == 2:
-            classes = args[0].split(',')
+            if args[0].startswith('-'):
+                classes = [ c for c in self.db.classes.keys()
+                            if not c in args[0][1:].split(',') ]
+            else:
+                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'), 'w')
-            writer = rcsv.writer(f, rcsv.colon_separated)
+            if not export_files and hasattr(cl, 'export_files'):
+                sys.stdout.write('Exporting %s WITHOUT the files\r\n'%
+                    classname)
+
+            f = open(os.path.join(dir, classname+'.csv'), 'wb')
+            writer = csv.writer(f, colon_separated)
 
             properties = cl.getprops()
-            propnames = properties.keys()
-            propnames.sort()
+            propnames = cl.export_propnames()
             fields = propnames[:]
             fields.append('is retired')
             writer.writerow(fields)
 
             # all nodes for this class
             for nodeid in cl.getnodeids():
+                if self.verbose:
+                    sys.stdout.write('\rExporting %s - %s'%(classname, nodeid))
+                    sys.stdout.flush()
                 writer.writerow(cl.export_list(propnames, nodeid))
+                if export_files and 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'), 'w')
-            journals = rcsv.writer(jf, rcsv.colon_separated)
+            jf = open(os.path.join(dir, classname+'-journals.csv'), 'wb')
+            if self.verbose:
+                sys.stdout.write("\nExporting Journal for %s\n" % classname)
+                sys.stdout.flush()
+            journals = csv.writer(jf, colon_separated)
             map(journals.writerow, cl.export_journals())
             jf.close()
         return 0
 
+    def do_exporttables(self, args):
+        ""'''Usage: exporttables [[-]class[,class]] export_dir
+        Export the database to colon-separated-value files, excluding the
+        files below $TRACKER_HOME/db/files/ (which can be archived separately).
+        To include the files, use the export command.
+
+        Optionally limit the export to just the named classes
+        or exclude the named classes, if the 1st argument starts with '-'.
+
+        This action exports the current data from the database into
+        colon-separated-value files that are placed in the nominated
+        destination directory.
+        '''
+        return self.do_export(args, export_files=False)
+
     def do_import(self, args):
-        '''Usage: import import_dir
-        Import a database from the directory containing CSV files, two per
-        class to import.
+        ""'''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:
 
@@ -1090,11 +1178,16 @@ Command help:
         '''
         if len(args) < 1:
             raise UsageError, _('Not enough arguments supplied')
-        if rcsv.error:
-            raise UsageError, _(rcsv.error)
         from roundup import hyperdb
 
-        for file in os.listdir(args[0]):
+        # 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'):
@@ -1103,22 +1196,31 @@ Command help:
             cl = self.get_class(classname)
 
             # ensure that the properties and the CSV file headings match
-            f = open(os.path.join(args[0], file))
-            reader = rcsv.reader(f, rcsv.colon_separated)
+            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 r in reader:
+            for n, r in enumerate(reader):
                 if file_props is None:
                     file_props = r
                     continue
+
+                if self.verbose:
+                    sys.stdout.write('\rImporting %s - %s'%(classname, n))
+                    sys.stdout.flush()
+
                 # do the import and figure the current highest nodeid
-                maxid = max(maxid, int(cl.import_list(file_props, r)))
+                nodeid = cl.import_list(file_props, r)
+                if hasattr(cl, 'import_files'):
+                    cl.import_files(dir, nodeid)
+                maxid = max(maxid, int(nodeid))
+            print
             f.close()
 
             # import the journals
-            f = open(os.path.join(args[0], classname + '-journals.csv'))
-            reader = rcsv.reader(f, rcsv.colon_separated)
+            f = open(os.path.join(args[0], classname + '-journals.csv'), 'r')
+            reader = csv.reader(f, colon_separated)
             cl.import_journals(reader)
             f.close()
 
@@ -1126,29 +1228,30 @@ Command help:
             print 'setting', classname, maxid+1
             self.db.setid(classname, str(maxid+1))
 
+        self.db_uncommitted = True
         return 0
 
     def do_pack(self, args):
-        '''Usage: pack period | date
+        ""'''Usage: pack period | date
+
+        Remove journal entries older than a period of time specified or
+        before a certain 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.
 
-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
 
-      "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
 
-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'''
@@ -1164,21 +1267,35 @@ Date format is "YYYY-MM-DD" eg:
         elif m['date']:
             pack_before = date.Date(value)
         self.db.pack(pack_before)
+        self.db_uncommitted = True
         return 0
 
-    def do_reindex(self, args):
-        '''Usage: reindex
+    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.
+        This will re-generate the search indexes for a tracker.
+        This will typically happen automatically.
         '''
-        self.db.indexer.force_reindex()
-        self.db.reindex()
+        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]
+        ""'''Usage: security [Role name]
         Display the Permissions available to one or all Roles.
         '''
         if len(args) == 1:
@@ -1204,11 +1321,43 @@ Date format is "YYYY-MM-DD" eg:
         for rolename, role in roles:
             print _('Role "%(name)s":')%role.__dict__
             for permission in role.permissions:
+                d = permission.__dict__
                 if permission.klass:
-                    print _(' %(description)s (%(name)s for "%(klass)s" '
-                        'only)')%permission.__dict__
+                    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)')%permission.__dict__
+                    print _(' %(description)s (%(name)s)')%d
+        return 0
+
+
+    def do_migrate(self, args):
+        '''Usage: migrate
+        Update a tracker's database to be compatible with the Roundup
+        codebase.
+
+        You should run the "migrate" command for your tracker once you've
+        installed the latest codebase. 
+
+        Do this before you use the web, command-line or mail interface and
+        before any users access the tracker.
+
+        This command will respond with either "Tracker updated" (if you've
+        not previously run it on an RDBMS backend) or "No migration action
+        required" (if you have run it, or have used another interface to the
+        tracker, or possibly because you are using anydbm).
+
+        It's safe to run this even if it's not required, so just get into
+        the habit.
+        '''
+        if getattr(self.db, 'db_version_updated'):
+            print _('Tracker updated')
+            self.db_uncommitted = True
+        else:
+            print _('No migration action required')
         return 0
 
     def run_command(self, args):
@@ -1228,6 +1377,9 @@ Date format is "YYYY-MM-DD" eg:
             self.help_commands()
             self.help_all()
             return 0
+        if command == 'config':
+            self.do_config(args[1:])
+            return 0
 
         # figure what the command is
         try:
@@ -1293,8 +1445,8 @@ Date format is "YYYY-MM-DD" eg:
     def interactive(self):
         '''Run in an interactive mode
         '''
-        print _('Roundup %s ready for input.'%roundup_version)
-        print _('Type "help" for help.')
+        print _('Roundup %s ready for input.\nType "help" for help.'
+            % roundup_version)
         try:
             import readline
         except ImportError:
@@ -1313,7 +1465,7 @@ Date format is "YYYY-MM-DD" eg:
             self.run_command(args)
 
         # exit.. check for transactions
-        if self.db and self.db.transactions:
+        if self.db and self.db_uncommitted:
             commit = raw_input(_('There are unsaved changes. Commit them (y/N)? '))
             if commit and commit[0].lower() == 'y':
                 self.db.commit()
@@ -1321,7 +1473,7 @@ Date format is "YYYY-MM-DD" eg:
 
     def main(self):
         try:
-            opts, args = getopt.getopt(sys.argv[1:], 'i:u:hcdsS:v')
+            opts, args = getopt.getopt(sys.argv[1:], 'i:u:hcdsS:vV')
         except getopt.GetoptError, e:
             self.usage(str(e))
             return 1
@@ -1337,31 +1489,34 @@ Date format is "YYYY-MM-DD" eg:
                 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
-            if opt == '-v':
+            elif opt == '-v':
                 print '%s (python %s)'%(roundup_version, sys.version.split()[0])
                 return 0
-            if opt == '-i':
+            elif opt == '-V':
+                self.verbose = 1
+            elif opt == '-i':
                 self.tracker_home = arg
-            if opt == '-c':
+            elif opt == '-c':
                 if self.separator != None:
                     self.usage('Only one of -c, -S and -s may be specified')
                     return 1
                 self.separator = ','
-            if opt == '-S':
+            elif opt == '-S':
                 if self.separator != None:
                     self.usage('Only one of -c, -S and -s may be specified')
                     return 1
                 self.separator = arg
-            if opt == '-s':
+            elif opt == '-s':
                 if self.separator != None:
                     self.usage('Only one of -c, -S and -s may be specified')
                     return 1
                 self.separator = ' '
-            if opt == '-d':
+            elif opt == '-d':
                 self.print_designator = 1
 
         # if no command - go interactive
@@ -1382,4 +1537,4 @@ if __name__ == '__main__':
     tool = AdminTool()
     sys.exit(tool.main())
 
-# vim: set filetype=python ts=4 sw=4 et si
+# vim: set filetype=python sts=4 sw=4 et si :
index 0b7ddebd73b23e75d61ba821b9dc6c7953b56224..a89223ae1be9689e9e3f3b5d6d5810e20bd3264b 100644 (file)
 # 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.27 2004-04-05 23:43:03 richard Exp $
+#
+# $Id: __init__.py,v 1.40 2007-11-07 20:47:12 richard Exp $
 
 '''Container for the hyperdb storage backend implementations.
-
-The __all__ variable is constructed containing only the backends which are
-available.
 '''
 __docformat__ = 'restructuredtext'
 
-__all__ = []
+import sys
 
-for backend in ['anydbm', ('mysql', 'MySQLdb'), ('bsddb', '_bsddb'),
-        'bsddb3', 'sqlite', 'metakit', ('postgresql', 'psycopg')]:
-    if len(backend) == 2:
-        backend, backend_module = backend
+# 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',),
+    'sqlite': ('pysqlite', 'pysqlite2', 'sqlite3', '_sqlite3'),
+}
+
+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:
-        backend_module = backend
+        vars[name] = module
+        return module
+
+def have_backend(name):
+    '''Is backend "name" available?'''
+    if name == 'tsearch2':
+        # currently not working
+        return 0
     try:
-        globals()[backend] = __import__('back_%s'%backend, globals())
-        __all__.append(backend)
+        get_backend(name)
+        return 1
     except ImportError, e:
-        if not str(e).startswith('No module named %s'%backend_module):
-            raise
+        for name in _modules.get(name, (name,)):
+            if str(e).startswith('No module named %s'%name):
+                return 0
+        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', 'postgresql':
+        if have_backend(name):
+            l.append(name)
+    return l
 
-# vim: set filetype=python ts=4 sw=4 et si
+# vim: set filetype=python sts=4 sw=4 et si :
index b79d1454d1443ce82924a46280f6b13d9b849fc7..17dfa78dfbc974766605f483a2bcc7e8ed93c770 100644 (file)
@@ -14,8 +14,8 @@
 # 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.141 2004-04-07 01:12:25 richard Exp $
+#
+#$Id: back_anydbm.py,v 1.211 2008-08-07 05:53:14 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
@@ -33,15 +33,30 @@ try:
 except AssertionError:
     print "WARNING: you should upgrade to python 2.1.3"
 
-import whichdb, os, marshal, re, weakref, string, copy
-from roundup import hyperdb, date, password, roundupdb, security
+import whichdb, os, marshal, re, weakref, string, copy, time, shutil, logging
+
+from roundup import hyperdb, date, password, roundupdb, security, support
+from roundup.support import reversed
+from roundup.backends import locking
+from roundup.i18n import _
+
 from blobfiles import FileStorage
 from sessions_dbm import Sessions, OneTimeKeys
-from indexer_dbm import Indexer
-from roundup.backends import locking
-from roundup.hyperdb import String, Password, Date, Interval, Link, \
-    Multilink, DatabaseError, Boolean, Number, Node
-from roundup.date import Range
+
+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
@@ -50,7 +65,7 @@ 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)?
@@ -68,20 +83,22 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         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.  
-        '''        
+        disabled.
+        '''
+        FileStorage.__init__(self, config.UMASK)
         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.dir)
+        self.indexer = Indexer(self)
         self.security = security.Security(self)
-        # ensure files are group readable and writable
-        os.umask(0002)
+        os.umask(config.UMASK)
 
         # lock it
         lockfilenm = os.path.join(self.dir, 'lock')
@@ -107,14 +124,23 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
     def getOTKManager(self):
         return OneTimeKeys(self)
 
-    def reindex(self):
-        for klass in self.classes.values():
-            for nodeid in klass.list():
-                klass.index(nodeid)
+    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) 
+        return '<back_anydbm instance at %x>'%id(self)
 
     #
     # Classes
@@ -122,20 +148,18 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
     def __getattr__(self, classname):
         '''A convenient way of calling self.getclass(classname).'''
         if self.classes.has_key(classname):
-            if __debug__:
-                print >>hyperdb.DEBUG, '__getattr__', (self, classname)
             return self.classes[classname]
         raise AttributeError, classname
 
     def addclass(self, cl):
-        if __debug__:
-            print >>hyperdb.DEBUG, '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,
@@ -143,8 +167,6 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
 
     def getclasses(self):
         '''Return a list of the names of all existing classes.'''
-        if __debug__:
-            print >>hyperdb.DEBUG, 'getclasses', (self,)
         l = self.classes.keys()
         l.sort()
         return l
@@ -154,8 +176,6 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
 
         If 'classname' is not a valid class name, a KeyError is raised.
         '''
-        if __debug__:
-            print >>hyperdb.DEBUG, 'getclass', (self, classname)
         try:
             return self.classes[classname]
         except KeyError:
@@ -167,8 +187,7 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
     def clear(self):
         '''Delete all database contents
         '''
-        if __debug__:
-            print >>hyperdb.DEBUG, 'clear', (self,)
+        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)
@@ -176,13 +195,17 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
                     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
         '''
-        if __debug__:
-            print >>hyperdb.DEBUG, 'getclassdb', (self, classname, mode)
         return self.opendb('nodes.%s'%classname, mode)
 
     def determine_db_type(self, path):
@@ -192,7 +215,8 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         if os.path.exists(path):
             db_type = whichdb.whichdb(path)
             if not db_type:
-                raise DatabaseError, "Couldn't identify database 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!
@@ -203,9 +227,6 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         '''Low-level database opener that gets around anydbm/dbm
            eccentricities.
         '''
-        if __debug__:
-            print >>hyperdb.DEBUG, 'opendb', (self, name, mode)
-
         # figure the class db type
         path = os.path.join(os.getcwd(), self.dir, name)
         db_type = self.determine_db_type(path)
@@ -213,19 +234,19 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         # new database? let anydbm pick the best dbm
         if not db_type:
             if __debug__:
-                print >>hyperdb.DEBUG, "opendb anydbm.open(%r, 'c')"%path
+                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 DatabaseError, \
-                "Couldn't open database - the required module '%s'"\
-                " is not available"%db_type
+            raise hyperdb.DatabaseError, \
+                _("Couldn't open database - the required module '%s'"\
+                " is not available")%db_type
         if __debug__:
-            print >>hyperdb.DEBUG, "opendb %r.open(%r, %r)"%(db_type, path,
-                mode)
+            logging.getLogger('hyperdb').debug("opendb %r.open(%r, %r)"%(db_type, path,
+                mode))
         return dbm.open(path, mode)
 
     #
@@ -259,9 +280,6 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
     def addnode(self, classname, nodeid, node):
         ''' add the specified node to its class's db
         '''
-        if __debug__:
-            print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
-
         # 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
@@ -278,16 +296,8 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
     def setnode(self, classname, nodeid, node):
         ''' change the specified node
         '''
-        if __debug__:
-            print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, node)
         self.dirtynodes.setdefault(classname, {})[nodeid] = 1
 
-        # update the activity time (dupe so we don't affect
-        # calling code's node assumptions)
-        node = node.copy()
-        node['activity'] = date.Date()
-        node['actor'] = self.getuid()
-
         # can't set without having already loaded the node
         self.cache[classname][nodeid] = node
         self.savenode(classname, nodeid, node)
@@ -296,7 +306,7 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         ''' perform the saving of data specified by the set/addnode
         '''
         if __debug__:
-            print >>hyperdb.DEBUG, 'savenode', (self, classname, nodeid, node)
+            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):
@@ -305,19 +315,18 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
             Note the "cache" parameter is not used, and exists purely for
             backward compatibility!
         '''
-        if __debug__:
-            print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid, db)
-
         # try the cache
         cache_dict = self.cache.setdefault(classname, {})
         if cache_dict.has_key(nodeid):
             if __debug__:
-                print >>hyperdb.TRACE, 'get %s %s cached'%(classname,
-                    nodeid)
+                logging.getLogger('hyperdb').debug('get %s%s cached'%(classname, nodeid))
+                self.stats['cache_hits'] += 1
             return cache_dict[nodeid]
 
         if __debug__:
-            print >>hyperdb.TRACE, 'get %s %s'%(classname, nodeid)
+            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:
@@ -340,14 +349,16 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         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.
         '''
-        if __debug__:
-            print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
+        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
@@ -367,30 +378,31 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
 
         # add the destroy commit action
         self.transactions.append((self.doDestroyNode, (classname, nodeid)))
+        self.transactions.append((FileStorage.destroy, (self, classname, nodeid)))
 
     def serialise(self, classname, node):
         '''Copy the node contents, converting non-marshallable data into
            marshallable data.
         '''
-        if __debug__:
-            print >>hyperdb.DEBUG, 'serialise', classname, node
         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):
+            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, Password) and v is not None:
+            if isinstance(prop, hyperdb.Password) and v is not None:
                 d[k] = str(v)
-            elif isinstance(prop, Date) and v is not None:
+            elif isinstance(prop, hyperdb.Date) and v is not None:
                 d[k] = v.serialise()
-            elif isinstance(prop, Interval) and v is not None:
+            elif isinstance(prop, hyperdb.Interval) and v is not None:
                 d[k] = v.serialise()
             else:
                 d[k] = v
@@ -399,8 +411,6 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
     def unserialise(self, classname, node):
         '''Decode the marshalled node data
         '''
-        if __debug__:
-            print >>hyperdb.DEBUG, 'unserialise', classname, node
         properties = self.getclass(classname).getprops()
         d = {}
         for k, v in node.items():
@@ -413,11 +423,11 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
             # get the property spec
             prop = properties[k]
 
-            if isinstance(prop, Date) and v is not None:
+            if isinstance(prop, hyperdb.Date) and v is not None:
                 d[k] = date.Date(v)
-            elif isinstance(prop, Interval) and v is not None:
+            elif isinstance(prop, hyperdb.Interval) and v is not None:
                 d[k] = date.Interval(v)
-            elif isinstance(prop, Password) and v is not None:
+            elif isinstance(prop, hyperdb.Password) and v is not None:
                 p = password.Password()
                 p.unpack(v)
                 d[k] = p
@@ -428,17 +438,10 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
     def hasnode(self, classname, nodeid, db=None):
         ''' determine if the database has a given node
         '''
-        if __debug__:
-            print >>hyperdb.DEBUG, 'hasnode', (self, classname, nodeid, db)
-
         # try the cache
         cache = self.cache.setdefault(classname, {})
         if cache.has_key(nodeid):
-            if __debug__:
-                print >>hyperdb.TRACE, 'has %s %s cached'%(classname, nodeid)
             return 1
-        if __debug__:
-            print >>hyperdb.TRACE, 'has %s %s'%(classname, nodeid)
 
         # not in the cache - check the database
         if db is None:
@@ -447,9 +450,6 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         return res
 
     def countnodes(self, classname, db=None):
-        if __debug__:
-            print >>hyperdb.DEBUG, 'countnodes', (self, classname, db)
-
         count = 0
 
         # include the uncommitted nodes
@@ -480,18 +480,23 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
             '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__:
-            print >>hyperdb.DEBUG, 'addjournal', (self, classname, nodeid,
-                action, params, creator, creation)
+            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__:
-            print >>hyperdb.DEBUG, 'setjournal', (self, classname, nodeid,
-                journal)
+            logging.getLogger('hyperdb').debug('setjournal %s%s %r'%(classname,
+                nodeid, journal))
         self.transactions.append((self.doSetJournal, (classname, nodeid,
             journal)))
 
@@ -501,9 +506,6 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
             Raise IndexError if the node doesn't exist (as per history()'s
             API)
         '''
-        if __debug__:
-            print >>hyperdb.DEBUG, 'getjournal', (self, classname, nodeid)
-
         # our journal result
         res = []
 
@@ -554,11 +556,9 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
     def pack(self, pack_before):
         ''' Delete all journal entries except "create" before 'pack_before'.
         '''
-        if __debug__:
-            print >>hyperdb.DEBUG, 'packjournal', (self, 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)
@@ -572,26 +572,41 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
                 last_set_entry = None
                 for entry in journal:
                     # unpack the entry
-                    (nodeid, date_stamp, self.journaltag, action, 
+                    (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):
+    def commit(self, fail_ok=False):
         ''' Commit the current transactions.
+
+        Save all data changed since the database was opened or since the
+        last commit() or rollback().
+
+        fail_ok indicates that the commit is allowed to fail. This is used
+        in the web interface when committing cleaning of the session
+        database. We don't care if there's a concurrency issue there.
+
+        The only backend this seems to affect is postgres.
         '''
-        if __debug__:
-            print >>hyperdb.DEBUG, 'commit', (self,)
+        logging.getLogger('hyperdb').info('commit %s transactions'%(
+            len(self.transactions)))
 
         # keep a handle to all the database files opened
         self.databases = {}
@@ -607,9 +622,13 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
                 db.close()
             del self.databases
 
+        # clear the transactions list now so the blobfile implementation
+        # doesn't think there's still pending file commits when it tries
+        # to access the file data
+        self.transactions = []
+
         # reindex the nodes that request it
         for classname, nodeid in filter(None, reindex.keys()):
-            print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid)
             self.getclass(classname).index(nodeid)
 
         # save the indexer state
@@ -635,10 +654,6 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         return self.databases[db_name]
 
     def doSaveNode(self, classname, nodeid, node):
-        if __debug__:
-            print >>hyperdb.DEBUG, 'doSaveNode', (self, classname, nodeid,
-                node)
-
         db = self.getCachedClassDB(classname)
 
         # now save the marshalled data
@@ -665,10 +680,7 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
 
         # handle supply of the special journalling parameters (usually
         # supplied on importing an existing database)
-        if creator:
-            journaltag = creator
-        else:
-            journaltag = self.getuid()
+        journaltag = creator
         if creation:
             journaldate = creation.serialise()
         else:
@@ -677,9 +689,6 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         # create the journal entry
         entry = (nodeid, journaldate, journaltag, action, params)
 
-        if __debug__:
-            print >>hyperdb.DEBUG, 'doSaveJournal', entry
-
         db = self.getCachedJournalDB(classname)
 
         # now insert the journal entry
@@ -700,14 +709,12 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
             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):
-        if __debug__:
-            print >>hyperdb.DEBUG, 'doDestroyNode', (self, classname, nodeid)
-
         # delete from the class database
         db = self.getCachedClassDB(classname)
         if db.has_key(nodeid):
@@ -718,14 +725,12 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         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.
         '''
-        if __debug__:
-            print >>hyperdb.DEBUG, 'rollback', (self, )
+        logging.getLogger('hyperdb').info('rollback %s transactions'%(
+            len(self.transactions)))
+
         for method, args in self.transactions:
             # delete temporary files
             if method == self.doStoreFile:
@@ -748,32 +753,6 @@ _marker = []
 class Class(hyperdb.Class):
     '''The handle to a particular class of nodes 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.
-        '''
-        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)
-
-        self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
-        self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
-
     def enableJournalling(self):
         '''Turn journalling on for this class
         '''
@@ -793,13 +772,13 @@ class Class(hyperdb.Class):
 
         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.
 
@@ -818,7 +797,7 @@ class Class(hyperdb.Class):
             raise KeyError, '"id" is reserved'
 
         if self.db.journaltag is None:
-            raise DatabaseError, 'Database open read-only'
+            raise hyperdb.DatabaseError, _('Database open read-only')
 
         if propvalues.has_key('creation') or propvalues.has_key('activity'):
             raise KeyError, '"creation" and "activity" are reserved'
@@ -843,7 +822,7 @@ class Class(hyperdb.Class):
                 raise KeyError, '"%s" has no property "%s"'%(self.classname,
                     key)
 
-            if value is not None and isinstance(prop, Link):
+            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
@@ -865,9 +844,11 @@ class Class(hyperdb.Class):
                     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
+            elif isinstance(prop, hyperdb.Multilink):
+                if value is None:
+                    value = []
+                if not hasattr(value, '__iter__'):
+                    raise TypeError, 'new property "%s" not an iterable of ids'%key
 
                 # clean up and validate the list of links
                 link_class = self.properties[key].classname
@@ -897,30 +878,32 @@ class Class(hyperdb.Class):
                         self.db.addjournal(link_class, nodeid, 'link',
                             (self.classname, newid, key))
 
-            elif isinstance(prop, String):
+            elif isinstance(prop, hyperdb.String):
                 if type(value) != type('') and type(value) != type(u''):
                     raise TypeError, 'new property "%s" not a string'%key
-                self.db.indexer.add_text((self.classname, newid, key), value)
+                if prop.indexme:
+                    self.db.indexer.add_text((self.classname, newid, key),
+                        value)
 
-            elif isinstance(prop, Password):
+            elif isinstance(prop, hyperdb.Password):
                 if not isinstance(value, password.Password):
                     raise TypeError, 'new property "%s" not a Password'%key
 
-            elif isinstance(prop, Date):
+            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, Interval):
+            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, Number):
+            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, Boolean):
+            elif value is not None and isinstance(prop, hyperdb.Boolean):
                 try:
                     int(value)
                 except ValueError:
@@ -932,10 +915,8 @@ class Class(hyperdb.Class):
                 continue
             if key == self.key:
                 raise ValueError, 'key property "%s" is required'%key
-            if isinstance(prop, Multilink):
+            if isinstance(prop, hyperdb.Multilink):
                 propvalues[key] = []
-            else:
-                propvalues[key] = None
 
         # done
         self.db.addnode(self.classname, newid, propvalues)
@@ -1031,7 +1012,7 @@ class Class(hyperdb.Class):
 
         if not d.has_key(propname):
             if default is _marker:
-                if isinstance(prop, Multilink):
+                if isinstance(prop, hyperdb.Multilink):
                     return []
                 else:
                     return None
@@ -1039,14 +1020,14 @@ class Class(hyperdb.Class):
                 return default
 
         # return a dupe of the list so code doesn't get confused
-        if isinstance(prop, Multilink):
+        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.
 
@@ -1067,9 +1048,16 @@ class Class(hyperdb.Class):
         '''
         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        
+        return propvalues
 
     def set_inner(self, nodeid, **propvalues):
         ''' Called by set, in-between the audit and react calls.
@@ -1084,7 +1072,7 @@ class Class(hyperdb.Class):
             raise KeyError, '"id" is reserved'
 
         if self.db.journaltag is None:
-            raise DatabaseError, 'Database open read-only'
+            raise hyperdb.DatabaseError, _('Database open read-only')
 
         node = self.db.getnode(self.classname, nodeid)
         if node.has_key(self.db.RETIRED_FLAG):
@@ -1122,7 +1110,7 @@ class Class(hyperdb.Class):
             journalvalues[propname] = current
 
             # do stuff based on the prop type
-            if isinstance(prop, Link):
+            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('')):
@@ -1150,9 +1138,11 @@ class Class(hyperdb.Class):
                         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'\
+            elif isinstance(prop, hyperdb.Multilink):
+                if value is None:
+                    value = []
+                if not hasattr(value, '__iter__'):
+                    raise TypeError, 'new property "%s" not an iterable of'\
                         ' ids'%propname
                 link_class = self.properties[propname].classname
                 l = []
@@ -1213,35 +1203,36 @@ class Class(hyperdb.Class):
                 if l:
                     journalvalues[propname] = tuple(l)
 
-            elif isinstance(prop, String):
+            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
-                self.db.indexer.add_text((self.classname, nodeid, propname),
-                    value)
+                if prop.indexme:
+                    self.db.indexer.add_text((self.classname, nodeid, propname),
+                        value)
 
-            elif isinstance(prop, Password):
+            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, Date):
+            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, Interval):
+            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, Number):
+            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, Boolean):
+            elif value is not None and isinstance(prop, hyperdb.Boolean):
                 try:
                     int(value)
                 except ValueError:
@@ -1253,6 +1244,10 @@ class Class(hyperdb.Class):
         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)
 
@@ -1263,10 +1258,10 @@ class Class(hyperdb.Class):
 
     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.
 
@@ -1274,7 +1269,7 @@ class Class(hyperdb.Class):
         to modify the "creation" or "activity" properties cause a KeyError.
         '''
         if self.db.journaltag is None:
-            raise DatabaseError, 'Database open read-only'
+            raise hyperdb.DatabaseError, _('Database open read-only')
 
         self.fireAuditors('retire', nodeid, None)
 
@@ -1292,7 +1287,7 @@ class Class(hyperdb.Class):
         Make node available for all operations like it was before retirement.
         '''
         if self.db.journaltag is None:
-            raise DatabaseError, 'Database open read-only'
+            raise hyperdb.DatabaseError, _('Database open read-only')
 
         node = self.db.getnode(self.classname, nodeid)
         # check if key property was overrided
@@ -1338,7 +1333,7 @@ class Class(hyperdb.Class):
         support the session storage of the cgi interface.
         '''
         if self.db.journaltag is None:
-            raise DatabaseError, 'Database open read-only'
+            raise hyperdb.DatabaseError, _('Database open read-only')
         self.db.destroynode(self.classname, nodeid)
 
     def history(self, nodeid):
@@ -1373,7 +1368,7 @@ class Class(hyperdb.Class):
         property doesn't exist, KeyError is raised.
         '''
         prop = self.getprops()[propname]
-        if not isinstance(prop, String):
+        if not isinstance(prop, hyperdb.String):
             raise TypeError, 'key properties must be String'
         self.key = propname
 
@@ -1381,31 +1376,6 @@ class Class(hyperdb.Class):
         '''Return the name of the key property for this class or None.'''
         return self.key
 
-    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:
-
-        1. key property
-        2. "name" property
-        3. "title" property
-        4. first property from the sorted property name list
-        '''
-        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]
-
     # 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.
@@ -1423,6 +1393,8 @@ class Class(hyperdb.Class):
                 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:
@@ -1432,26 +1404,25 @@ class Class(hyperdb.Class):
 
     # change from spec - allows multiple props to match
     def find(self, **propspec):
-        '''Get the ids of items in this class which link to the given items.
+        '''Get the ids of nodes in this class which link to the given nodes.
 
-        'propspec' consists of keyword args propname=itemid or
-                   propname={itemid:1, }
+        '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 item in this class whose 'propname' property links to any of the
-        itemids 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:
+        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, Link) and not isinstance(prop, Multilink):
+            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
@@ -1463,19 +1434,23 @@ class Class(hyperdb.Class):
                 if item.has_key(self.db.RETIRED_FLAG):
                     continue
                 for propname, itemids in propspec:
-                    # can't test if the item doesn't have this property
-                    if not item.has_key(propname):
-                        continue
                     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, Link) and itemids.has_key(value):
+                    if isinstance(prop, hyperdb.Link) and itemids.has_key(value):
                         l.append(id)
                         break
-                    elif isinstance(prop, Multilink):
+                    elif isinstance(prop, hyperdb.Multilink):
                         hit = 0
                         for v in value:
                             if itemids.has_key(v):
@@ -1493,12 +1468,12 @@ class Class(hyperdb.Class):
         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, String):
+            if not isinstance(prop, hyperdb.String):
                 raise TypeError, "'%s' not a String property"%propname
             requirements[propname] = requirements[propname].lower()
         l = []
@@ -1536,32 +1511,47 @@ class Class(hyperdb.Class):
         l.sort()
         return l
 
-    def getnodeids(self, db=None):
+    def getnodeids(self, db=None, retired=None):
         ''' Return a list of ALL nodeids
-        '''
-        if __debug__:
-            print >>hyperdb.DEBUG, 'getnodeids', (self, self.classname, db)
 
+            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)
-        res = res + db.keys()
+            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)
+            # 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+$')):
+    def _filter(self, search_matches, filterspec, proptree,
+            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.
@@ -1571,30 +1561,32 @@ class Class(hyperdb.Class):
         "sort" and "group" are (dir, prop) where dir is '+', '-' or None
         and prop is a prop name or None
 
-        "search_matches" is {nodeid: marker}
+        "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. Unless the property
-        is a Multilink, in which case the item's property list must
-        match the filterspec list.
+        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 = 0
-        MULTILINK = 1
-        STRING = 2
-        DATE = 3
-        INTERVAL = 4
-        OTHER = 6
-        
-        timezone = self.db.getUserTimezone()
+        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, Link):
+            if isinstance(propclass, hyperdb.Link):
                 if type(v) is not type([]):
                     v = [v]
                 u = []
@@ -1604,55 +1596,64 @@ class Class(hyperdb.Class):
                         entry = None
                     u.append(entry)
                 l.append((LINK, k, u))
-            elif isinstance(propclass, Multilink):
+            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, String) and k != 'id':
+            elif isinstance(propclass, hyperdb.String) and k != 'id':
                 if type(v) is not type([]):
                     v = [v]
-                m = []
                 for v in v:
                     # simple glob searching
                     v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
                     v = v.replace('?', '.')
                     v = v.replace('*', '.*?')
-                    m.append(v)
-                m = re.compile('(%s)'%('|'.join(m)), re.I)
-                l.append((STRING, k, m))
-            elif isinstance(propclass, Date):
+                    l.append((STRING, k, re.compile(v, re.I)))
+            elif isinstance(propclass, hyperdb.Date):
                 try:
-                    date_rng = Range(v, date.Date, offset=timezone)
+                    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, Interval):
+            elif isinstance(propclass, hyperdb.Interval):
                 try:
-                    intv_rng = Range(v, date.Interval)
+                    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, Boolean):
-                if type(v) is type(''):
-                    bv = v.lower() in ('yes', 'true', 'on', '1')
-                else:
-                    bv = v
+
+            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 isinstance(propclass, Number):
-                l.append((OTHER, k, int(v)))
-            else:
-                l.append((OTHER, k, v))
-        filterspec = l
 
+            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
-        l = []
+        matches = []
         cldb = self.db.getclassdb(cn)
+        t = 0
         try:
             # TODO: only full-scan once (use items())
             for nodeid in self.getnodeids(cldb):
@@ -1662,171 +1663,152 @@ class Class(hyperdb.Class):
                 # apply filter
                 for t, k, v in filterspec:
                     # handle the id prop
-                    if k == 'id' and v == nodeid:
+                    if k == 'id':
+                        if nodeid not in v:
+                            break
                         continue
 
-                    # make sure the node has the property
-                    if not node.has_key(k):
-                        # this node doesn't have this property, so reject it
-                        break
+                    # 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
-                        if node[k] not in v:
-                            break
+                        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
-                        have = node[k]
-                        # check for matching the absence of multilink values
-                        if not v and have:
-                            break
+                        nv = node.get(k, [])
 
-                        # othewise, make sure this node has each of the
-                        # required values
-                        for want in v:
-                            if want not in have:
-                                break
+                        # check for matching the absence of multilink values
+                        if not v:
+                            match = not nv
                         else:
-                            continue
-                        break
+                            # 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 node[k] is None:
-                            break
+                        if nv is None:
+                            nv = ''
                         # RE search
-                        if not v.search(node[k]):
-                            break
+                        match = v.search(nv)
                     elif t == DATE or t == INTERVAL:
-                        if node[k] is None:
-                            break
-                        if v.to_value:
-                            if not (v.from_value <= node[k] and v.to_value >= node[k]):
-                                break
+                        if nv is None:
+                            match = v is None
                         else:
-                            if not (v.from_value <= node[k]):
-                                break
+                            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
-                        if node[k] != v:
-                            break
+                        match = nv in v
+                    if not match:
+                        break
                 else:
-                    l.append((nodeid, node))
+                    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
+
+            # add sorting information to the proptree
+            JPROPS = {'actor':1, 'activity':1, 'creator':1, 'creation':1}
+            children = []
+            if proptree:
+                children = proptree.sortable_children()
+            for pt in children:
+                dir = pt.sort_direction
+                prop = pt.name
+                assert (dir and prop)
+                propclass = props[prop]
+                pt.sort_ids = []
+                is_pointer = isinstance(propclass,(hyperdb.Link,
+                    hyperdb.Multilink))
+                if not is_pointer:
+                    pt.sort_result = []
+                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
+                                v = None
+                                if isinstance(propclass, hyperdb.Multilink):
+                                    v = []
+                                if prop == 'id':
+                                    v = int (itemid)
+                                pt.sort_ids.append(v)
+                                if not is_pointer:
+                                    pt.sort_result.append(v)
+                                continue
+
+                        # missing (None) values are always sorted first
+                        if v is None:
+                            pt.sort_ids.append(v)
+                            if not is_pointer:
+                                pt.sort_result.append(v)
+                            continue
+
+                        if isinstance(propclass, hyperdb.Link):
+                            lcn = propclass.classname
+                            link = self.db.classes[lcn]
+                            key = link.orderprop()
+                            child = pt.propdict[key]
+                            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)
+                                r = lcache[v][key]
+                                child.propdict[key].sort_ids.append(r)
+                            else:
+                                child.propdict[key].sort_ids.append(v)
+                        pt.sort_ids.append(v)
+                        if not is_pointer:
+                            r = propclass.sort_repr(pt.parent.cls, v, pt.name)
+                            pt.sort_result.append(r)
+                finally:
+                    # if we opened the link class db, close it now
+                    if lcldb is not None:
+                        lcldb.close()
+                del lcache
         finally:
             cldb.close()
-        l.sort()
-
-        # filter based on full text search
-        if search_matches is not None:
-            k = []
-            for v in l:
-                if search_matches.has_key(v[0]):
-                    k.append(v)
-            l = k
-
-        # now, sort the result
-        def sortfun(a, b, sort=sort, group=group, properties=self.getprops(),
-                db = self.db, cl=self):
-            a_id, an = a
-            b_id, bn = b
-            # sort by group and then sort
-            for dir, prop in group, sort:
-                if dir is None or prop is None: continue
-
-                # sorting is class-specific
-                propclass = properties[prop]
-
-                # handle the properties that might be "faked"
-                # also, handle possible missing properties
-                try:
-                    if not an.has_key(prop):
-                        an[prop] = cl.get(a_id, prop)
-                    av = an[prop]
-                except KeyError:
-                    # the node doesn't have a value for this property
-                    if isinstance(propclass, Multilink): av = []
-                    else: av = ''
-                try:
-                    if not bn.has_key(prop):
-                        bn[prop] = cl.get(b_id, prop)
-                    bv = bn[prop]
-                except KeyError:
-                    # the node doesn't have a value for this property
-                    if isinstance(propclass, Multilink): bv = []
-                    else: bv = ''
-
-                # String and Date values are sorted in the natural way
-                if isinstance(propclass, String):
-                    # clean up the strings
-                    if av and av[0] in string.uppercase:
-                        av = av.lower()
-                    if bv and bv[0] in string.uppercase:
-                        bv = bv.lower()
-                if (isinstance(propclass, String) or
-                        isinstance(propclass, Date)):
-                    # it might be a string that's really an integer
-                    try:
-                        av = int(av)
-                        bv = int(bv)
-                    except:
-                        pass
-                    if dir == '+':
-                        r = cmp(av, bv)
-                        if r != 0: return r
-                    elif dir == '-':
-                        r = cmp(bv, av)
-                        if r != 0: return r
-
-                # Link properties are sorted according to the value of
-                # the "order" property on the linked nodes if it is
-                # present; or otherwise on the key string of the linked
-                # nodes; or finally on  the node ids.
-                elif isinstance(propclass, Link):
-                    link = db.classes[propclass.classname]
-                    if av is None and bv is not None: return -1
-                    if av is not None and bv is None: return 1
-                    if av is None and bv is None: continue
-                    if link.getprops().has_key('order'):
-                        if dir == '+':
-                            r = cmp(link.get(av, 'order'),
-                                link.get(bv, 'order'))
-                            if r != 0: return r
-                        elif dir == '-':
-                            r = cmp(link.get(bv, 'order'),
-                                link.get(av, 'order'))
-                            if r != 0: return r
-                    elif link.getkey():
-                        key = link.getkey()
-                        if dir == '+':
-                            r = cmp(link.get(av, key), link.get(bv, key))
-                            if r != 0: return r
-                        elif dir == '-':
-                            r = cmp(link.get(bv, key), link.get(av, key))
-                            if r != 0: return r
-                    else:
-                        if dir == '+':
-                            r = cmp(av, bv)
-                            if r != 0: return r
-                        elif dir == '-':
-                            r = cmp(bv, av)
-                            if r != 0: return r
 
-                else:
-                    # all other types just compare
-                    if dir == '+':
-                        r = cmp(av, bv)
-                    elif dir == '-':
-                        r = cmp(bv, av)
-                    if r != 0: return r
-                    
-            # end for dir, prop in sort, group:
-            # if all else fails, compare the ids
-            return cmp(a[0], b[0])
-
-        l.sort(sortfun)
-        return [i[0] for i in l]
+        # 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.
@@ -1851,7 +1833,7 @@ class Class(hyperdb.Class):
         '''
         d = self.properties.copy()
         if protected:
-            d['id'] = String()
+            d['id'] = hyperdb.String()
             d['creation'] = hyperdb.Date()
             d['activity'] = hyperdb.Date()
             d['creator'] = hyperdb.Link('user')
@@ -1884,36 +1866,6 @@ class Class(hyperdb.Class):
                     continue
                 self.db.indexer.add_text((self.classname, nodeid, prop), value)
 
-
-    #
-    # Detector interface
-    #
-    def audit(self, event, detector):
-        '''Register a detector
-        '''
-        l = self.auditors[event]
-        if detector not in l:
-            self.auditors[event].append(detector)
-
-    def fireAuditors(self, action, nodeid, newvalues):
-        '''Fire all registered auditors.
-        '''
-        for audit in self.auditors[action]:
-            audit(self.db, self, nodeid, newvalues)
-
-    def react(self, event, detector):
-        '''Register a detector
-        '''
-        l = self.reactors[event]
-        if detector not in l:
-            self.reactors[event].append(detector)
-
-    def fireReactors(self, action, nodeid, oldvalues):
-        '''Fire all registered reactors.
-        '''
-        for react in self.reactors[action]:
-            react(self.db, self, nodeid, oldvalues)
-
     #
     # import / export support
     #
@@ -1951,7 +1903,7 @@ class Class(hyperdb.Class):
             Return the nodeid of the node imported.
         '''
         if self.db.journaltag is None:
-            raise DatabaseError, 'Database open read-only'
+            raise hyperdb.DatabaseError, _('Database open read-only')
         properties = self.getprops()
 
         # make the new node's property map
@@ -2010,52 +1962,65 @@ class Class(hyperdb.Class):
             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):
+                        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)
-                        params[propname] = 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, date, user, action, params = 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):
+                    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, Interval):
+                    elif isinstance(prop, hyperdb.Interval):
                         value = date.Interval(value)
-                    elif isinstance(prop, Password):
+                    elif isinstance(prop, hyperdb.Password):
                         pwd = password.Password()
                         pwd.unpack(value)
                         value = pwd
                     params[propname] = value
-            r.append((nodeid, date.Date(date), user, action, params))
+            r.append((nodeid, date.Date(jdate), user, action, params))
 
         for nodeid, l in d.items():
             self.db.setjournal(self.classname, nodeid, l)
 
-class FileClass(Class, hyperdb.FileClass):
+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.
@@ -2064,7 +2029,15 @@ class FileClass(Class, hyperdb.FileClass):
        "default_mime_type" class attribute, which may be overridden by each
        node if the class defines a "type" String property.
     '''
-    default_mime_type = 'text/plain'
+    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
@@ -2083,34 +2056,12 @@ class FileClass(Class, hyperdb.FileClass):
         # do the database create
         newid = self.create_inner(**propvalues)
 
-        # and index!
-        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 import_list(self, propnames, proplist):
-        ''' Trap the "content" property...
-        '''
-        # dupe this list so we don't affect others
-        propnames = propnames[:]
 
-        # extract the "content" property from the proplist
-        i = propnames.index('content')
-        content = eval(proplist[i])
-        del propnames[i]
-        del proplist[i]
-
-        # do the normal import
-        newid = Class.import_list(self, propnames, proplist)
+        # fire reactors
+        self.fireReactors('create', newid, None)
 
-        # save off the "content" file
-        self.db.storefile(self.classname, newid, None, content)
         return newid
 
     def get(self, nodeid, propname, default=_marker, cache=1):
@@ -2123,7 +2074,7 @@ class FileClass(Class, hyperdb.FileClass):
             try:
                 return self.db.getfile(self.classname, nodeid, None)
             except IOError, (strerror):
-                # XXX by catching this we donot see an error in the log.
+                # 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:
@@ -2135,7 +2086,16 @@ class FileClass(Class, hyperdb.FileClass):
         ''' 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
@@ -2143,33 +2103,42 @@ class FileClass(Class, hyperdb.FileClass):
             content = propvalues['content']
             del propvalues['content']
 
-        # do the database create
+        # do the database update
         propvalues = self.set_inner(itemid, **propvalues)
 
         # do content?
         if content:
-            # store and index
+            # store and possibly index
             self.db.storefile(self.classname, itemid, None, content)
-            mime_type = propvalues.get('type', self.get(itemid, 'type'))
-            if not mime_type:
-                mime_type = self.default_mime_type
-            self.db.indexer.add_text((self.classname, itemid, 'content'),
-                content, mime_type)
+            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 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.
-        '''
-        d = Class.getprops(self, protected=protected).copy()
-        d['content'] = hyperdb.String()
-        return d
+    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):
@@ -2194,4 +2163,4 @@ class IssueClass(Class, roundupdb.IssueClass):
             properties['superseder'] = hyperdb.Multilink(classname)
         Class.__init__(self, db, classname, **properties)
 
-#
+# vim: set et sts=4 sw=4 :
diff --git a/roundup/backends/back_bsddb.py b/roundup/backends/back_bsddb.py
deleted file mode 100644 (file)
index 0251da8..0000000
+++ /dev/null
@@ -1,140 +0,0 @@
-#
-# 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_bsddb.py,v 1.29 2004-02-11 23:55:08 richard Exp $
-'''This module defines a backend that saves the hyperdatabase in BSDDB.
-'''
-__docformat__ = 'restructuredtext'
-
-import bsddb, os, marshal
-from roundup import hyperdb, date
-
-# these classes are so similar, we just use the anydbm methods
-from back_anydbm import Database, Class, FileClass, IssueClass
-
-#
-# Now the database
-#
-class Database(Database):
-    """A database for storing records containing flexible data types."""
-    #
-    # Class DBs
-    #
-    def clear(self):
-        for cn in self.classes.keys():
-            db = os.path.join(self.dir, 'nodes.%s'%cn)
-            bsddb.btopen(db, 'n')
-            db = os.path.join(self.dir, 'journals.%s'%cn)
-            bsddb.btopen(db, 'n')
-
-    def getclassdb(self, classname, mode='r'):
-        ''' grab a connection to the class db that will be used for
-            multiple actions
-        '''
-        path = os.path.join(os.getcwd(), self.dir, 'nodes.%s'%classname)
-        if os.path.exists(path):
-            return bsddb.btopen(path, mode)
-        else:
-            return bsddb.btopen(path, 'c')
-
-    def opendb(self, name, mode):
-        '''Low-level database opener that gets around anydbm/dbm
-           eccentricities.
-        '''
-        if __debug__:
-            print >>hyperdb.DEBUG, self, 'opendb', (self, name, mode)
-        # determine which DB wrote the class file
-        path = os.path.join(os.getcwd(), self.dir, name)
-        if not os.path.exists(path):
-            if __debug__:
-                print >>hyperdb.DEBUG, "opendb bsddb.open(%r, 'c')"%path
-            return bsddb.btopen(path, 'c')
-
-        # open the database with the correct module
-        if __debug__:
-            print >>hyperdb.DEBUG, "opendb bsddb.open(%r, %r)"%(path, mode)
-        return bsddb.btopen(path, mode)
-
-    #
-    # Journal
-    #
-    def getjournal(self, classname, nodeid):
-        ''' get the journal for id
-
-            Raise IndexError if the node doesn't exist (as per history()'s
-            API)
-        '''
-        if __debug__:
-            print >>hyperdb.DEBUG, 'getjournal', (self, classname, nodeid)
-
-        # 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 = bsddb.btopen(os.path.join(self.dir, 'journals.%s'%classname),
-                'r')
-        except bsddb.error, error:
-            if 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)
-        # more handling of bad journals
-        if not db.has_key(nodeid):
-            if res:
-                # we have some unsaved journal entries, be happy!
-                return res
-            raise IndexError, 'no such %s %s'%(classname, nodeid)
-        journal = marshal.loads(db[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 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 self.databases.has_key(db_name):
-            return self.databases[db_name]
-        else:
-            db = bsddb.btopen(os.path.join(self.dir, db_name), 'c')
-            self.databases[db_name] = db
-            return db
-
diff --git a/roundup/backends/back_bsddb3.py b/roundup/backends/back_bsddb3.py
deleted file mode 100644 (file)
index dbf8675..0000000
+++ /dev/null
@@ -1,138 +0,0 @@
-#
-# 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_bsddb3.py,v 1.22 2004-02-11 23:55:08 richard Exp $
-'''This module defines a backend that saves the hyperdatabase in BSDDB3.
-'''
-__docformat__ = 'restructuredtext'
-
-import bsddb3, os, marshal
-from roundup import hyperdb, date
-
-# these classes are so similar, we just use the anydbm methods
-from back_anydbm import Database, Class, FileClass, IssueClass
-
-#
-# Now the database
-#
-class Database(Database):
-    """A database for storing records containing flexible data types."""
-    #
-    # Class DBs
-    #
-    def clear(self):
-        for cn in self.classes.keys():
-            db = os.path.join(self.dir, 'nodes.%s'%cn)
-            bsddb3.btopen(db, 'n')
-            db = os.path.join(self.dir, 'journals.%s'%cn)
-            bsddb3.btopen(db, 'n')
-
-    def getclassdb(self, classname, mode='r'):
-        ''' grab a connection to the class db that will be used for
-            multiple actions
-        '''
-        path = os.path.join(os.getcwd(), self.dir, 'nodes.%s'%classname)
-        if os.path.exists(path):
-            return bsddb3.btopen(path, mode)
-        else:
-            return bsddb3.btopen(path, 'c')
-
-    def opendb(self, name, mode):
-        '''Low-level database opener that gets around anydbm/dbm
-           eccentricities.
-        '''
-        if __debug__:
-            print >>hyperdb.DEBUG, self, 'opendb', (self, name, mode)
-        # determine which DB wrote the class file
-        path = os.path.join(os.getcwd(), self.dir, name)
-        if not os.path.exists(path):
-            if __debug__:
-                print >>hyperdb.DEBUG, "opendb bsddb3.open(%r, 'c')"%path
-            return bsddb3.btopen(path, 'c')
-
-        # open the database with the correct module
-        if __debug__:
-            print >>hyperdb.DEBUG, "opendb bsddb3.open(%r, %r)"%(path, mode)
-        return bsddb3.btopen(path, mode)
-
-    #
-    # Journal
-    #
-    def getjournal(self, classname, nodeid):
-        ''' get the journal for id
-
-            Raise IndexError if the node doesn't exist (as per history()'s
-            API)
-        '''
-        if __debug__:
-            print >>hyperdb.DEBUG, 'getjournal', (self, classname, nodeid)
-
-        # 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 = bsddb3.btopen(os.path.join(self.dir, 'journals.%s'%classname),
-                'r')
-        except bsddb3._db.DBNoSuchFileError:
-            if res:
-                # we have unsaved journal entries, return them
-                return res
-            raise IndexError, 'no such %s %s'%(classname, nodeid)
-        # more handling of bad journals
-        if not db.has_key(nodeid):
-            db.close()
-            if res:
-                # we have some unsaved journal entries, be happy!
-                return res
-            raise IndexError, 'no such %s %s'%(classname, nodeid)
-        journal = marshal.loads(db[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 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 self.databases.has_key(db_name):
-            return self.databases[db_name]
-        else:
-            db = bsddb3.btopen(os.path.join(self.dir, db_name), 'c')
-            self.databases[db_name] = db
-            return db
-
diff --git a/roundup/backends/back_metakit.py b/roundup/backends/back_metakit.py
deleted file mode 100755 (executable)
index 1de4f53..0000000
+++ /dev/null
@@ -1,2068 +0,0 @@
-# $Id: back_metakit.py,v 1.70 2004-04-02 05:58:45 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 metakit
-from sessions_dbm import Sessions, OneTimeKeys
-import re, marshal, os, sys, time, calendar
-from indexer_dbm 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
-
-# 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):
-    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.config.DATABASE, self._db)
-        self.security = security.Security(self)
-
-        os.umask(0002)
-
-    def post_init(self):
-        if self.indexer.should_reindex():
-            self.reindex()
-
-    def refresh_database(self):
-        # XXX handle refresh
-        self.reindex()
-
-    def reindex(self):
-        for klass in self.classes.values():
-            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.
-        '''
-        self.classes[cl.classname] = cl
-        if self.tables.find(name=cl.classname) < 0:
-            self.tables.append(name=cl.classname)
-
-        # add default Edit and View permissions
-        self.security.addPermission(name="Edit", klass=cl.classname,
-            description="User is allowed to edit "+cl.classname)
-        self.security.addPermission(name="View", klass=cl.classname,
-            description="User is allowed to access "+cl.classname)
-
-    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=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:
-                print "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)
-            pkgnm = self.config.__name__.split('.')[0]
-            schemamod = sys.modules.get(pkgnm+'.dbinit', None)
-            if schemamod:
-                if os.path.exists(schemamod.__file__):
-                    schematm = os.path.getmtime(schemamod.__file__)
-                    if schematm < dbtm:
-                        # found schema mod - it's older than the db
-                        self.fastopen = 1
-                else:
-                     # can't find schemamod - must be frozen
-                    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
-        '''
-        pass
-
-    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',
-}
-
-_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 (properties.has_key('creation') or properties.has_key('activity')
-            or properties.has_key('creator') or properties.has_key('actor')):
-            raise ValueError, '"creation", "activity" and "creator" are '\
-                  'reserved'
-        if hasattr(db, classname):
-            raise ValueError, "Class %s already exists"%classname
-
-        self.db = db
-        self.classname = classname
-        self.key = None
-        self.ruprops = properties
-        self.privateprops = { 'id' : hyperdb.String(),
-                              'activity' : hyperdb.Date(),
-                              'actor' : hyperdb.Link('user'),
-                              'creation' : hyperdb.Date(),
-                              'creator'  : hyperdb.Link('user') }
-
-        # event -> list of callables
-        self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
-        self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
-
-        view = self.__getview()
-        self.maxid = 1
-        if view:
-            self.maxid = view[-1].id + 1
-        self.uncommitted = {}
-        self.comactions = []
-        self.rbactions = []
-
-        # people reach inside!!
-        self.properties = self.ruprops
-        self.db.addclass(self)
-        self.idcache = {}
-
-        # default is to journal changes
-        self.do_journal = 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
-        
-    #
-    # Detector/reactor interface
-    #
-    def audit(self, event, detector):
-        '''Register a detector
-        '''
-        l = self.auditors[event]
-        if detector not in l:
-            self.auditors[event].append(detector)
-
-    def fireAuditors(self, action, nodeid, newvalues):
-       '''Fire all registered auditors.
-        '''
-       for audit in self.auditors[action]:
-            audit(self.db, self, nodeid, newvalues)
-
-    def react(self, event, detector):
-       '''Register a reactor
-       '''
-       l = self.reactors[event]
-       if detector not in l:
-           self.reactors[event].append(detector)
-
-    def fireReactors(self, action, nodeid, oldvalues):
-        '''Fire all registered reactors.
-        '''
-        for react in self.reactors[action]:
-            react(self.db, self, nodeid, oldvalues)
-            
-    # --- 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(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(rutyp.__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())))
-                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 = int(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 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: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})
-        '''
-        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):
-        ''' 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 = []
-        for row in self.getview():
-            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}
-
-        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.
-        '''        
-        # search_matches is None or a set (dict of {nodeid: {propname:[nodeid,...]}})
-        # filterspec is a dict {propname:value}
-        # sort and group are (dir, prop) where dir is '+', '-' or None
-        #                    and prop is a prop name or None
-
-        timezone = self.db.getUserTimezone()
-
-        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:
-                    try:
-                        item = int(item)
-                    except (TypeError, ValueError):
-                        item = int(self.db.getclass(prop.classname).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]
-                m = []
-                for v in value:
-                    # simple glob searching
-                    v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v)
-                    v = v.replace('?', '.')
-                    v = v.replace('*', '.*?')
-                    m.append(v)
-                regexes[propname] = re.compile('(%s)'%('|'.join(m)), 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 = Range(value, date.Date, offset=timezone)
-                    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):
-                where[propname] = int(value)
-            else:
-                where[propname] = str(value)
-        v = self.getview()
-        #print "filter start at  %s" % time.time() 
-        if where:
-            where_higherbound = where.copy()
-            where_higherbound.update(wherehigh)
-            v = v.select(where, where_higherbound)
-        #print "filter where at  %s" % time.time() 
-
-        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 sv:
-                        return 0
-                    for id in values:
-                        if sv.find(fid=id) == -1:
-                            return 0
-                return 1
-            iv = v.filter(ff)
-            v = v.remapwith(iv)
-
-        #print "filter mlcrit at %s" % time.time() 
-        
-        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)
-        
-        #print "filter orcrit at %s" % time.time() 
-        if regexes:
-            def ff(row, r=regexes):
-                for propname, regex in r.items():
-                    val = str(getattr(row, propname))
-                    if not regex.search(val):
-                        return 0
-                return 1
-            
-            iv = v.filter(ff)
-            v = v.remapwith(iv)
-        #print "filter regexs at %s" % time.time() 
-        
-        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:
-                    print "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:
-                        print "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)
-                    if linkclass.getprops().has_key('order'):
-                        propname = 'order'
-                    else:
-                        propname = linkclass.labelprop()
-                    prop = getattr(v, propname)
-                if isreversed:
-                    rev.append(prop)
-                sortspec.append(prop)
-            v = v.sortrev(sortspec, rev)[:] #XXX Metakit bug
-        #print "filter sort   at %s" % time.time() 
-            
-        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)
-        return rslt
-    
-    def hasnode(self, nodeid):
-        '''Determine if the given nodeid actually exists
-        '''
-        return int(nodeid) < self.maxid
-    
-    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:
-
-        1. key property
-        2. "name" property
-        3. "title" property
-        4. first property from the sorted property name list
-        '''
-        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 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 = int(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.
-        '''
-        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':
-                    for propname, value in params.items():
-                        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)
-                        params[propname] = value
-                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, date, 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(date), 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    : 'I',
-}
-class FileClass(Class, hyperdb.FileClass):
-    ''' like Class but with a content property
-    '''
-    default_mime_type = 'text/plain'
-    def __init__(self, db, classname, **properties):
-        properties['content'] = FileName()
-        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 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)
-        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)
-
-        # 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)
-        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)
-            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, itemid, 'content'),
-                content, mimetype)
-
-            # 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)
-
-        self.fireReactors('set', oldnode, propvalues)
-
-    def index(self, nodeid):
-        Class.index(self, nodeid)
-        mimetype = self.get(nodeid, 'type')
-        if not mimetype:
-            mimetype = self.default_mime_type
-        self.db.indexer.add_text((self.classname, nodeid, 'content'),
-                    self.get(nodeid, 'content'), mimetype)
-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 Indexer(Indexer):
-    disallows = {'THE':1, 'THIS':1, 'ZZZ':1, 'THAT':1, 'WITH':1}
-    def __init__(self, path, datadb):
-        self.path = os.path.join(path, 'index.mk4')
-        self.db = metakit.storage(self.path, 1)
-        self.datadb = datadb
-        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.disallows.has_key(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[i] = (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
-
index be07b8706158fc4c6f854f90ea158c1fb469682f..ccf7803f990920f22f76b36ee48a1f842cf2b014 100644 (file)
@@ -1,3 +1,4 @@
+#$Id: back_mysql.py,v 1.75 2008-02-27 08:32:50 richard Exp $
 #
 # Copyright (c) 2003 Martynas Sklyzmantas, Andrey Lebedev <andrey@micro.lt>
 #
@@ -12,7 +13,7 @@
 How to implement AUTO_INCREMENT:
 
 mysql> create table foo (num integer auto_increment primary key, name
-varchar(255)) AUTO_INCREMENT=1 type=InnoDB;
+varchar(255)) AUTO_INCREMENT=1 ENGINE=InnoDB;
 
 ql> insert into foo (name) values ('foo5');
 Query OK, 1 row affected (0.00 sec)
@@ -38,15 +39,24 @@ 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):
-        conn = MySQLdb.connect(config.MYSQL_DBHOST, config.MYSQL_DBUSER,
-            config.MYSQL_DBPASSWORD)
+        kwargs = connection_dict(config)
+        conn = MySQLdb.connect(**kwargs)
         try:
-            conn.select_db(config.MYSQL_DBNAME)
+            conn.select_db(config.RDBMS_NAME)
         except:
             # no, it doesn't exist
             pass
@@ -54,13 +64,15 @@ def db_nuke(config):
             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__:
-                    print >>hyperdb.DEBUG, 'DROP TABLE %s'%table[0]
-                cursor.execute("DROP TABLE %s"%table[0])
-            if __debug__:
-                print >>hyperdb.DEBUG, "DROP DATABASE %s"%config.MYSQL_DBNAME
-            cursor.execute("DROP DATABASE %s"%config.MYSQL_DBNAME)
+                    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()
 
@@ -69,42 +81,35 @@ def db_nuke(config):
 
 def db_create(config):
     """Create the database."""
-    conn = MySQLdb.connect(config.MYSQL_DBHOST, config.MYSQL_DBUSER,
-        config.MYSQL_DBPASSWORD)
+    kwargs = connection_dict(config)
+    conn = MySQLdb.connect(**kwargs)
     cursor = conn.cursor()
-    if __debug__:
-        print >>hyperdb.DEBUG, "CREATE DATABASE %s"%config.MYSQL_DBNAME
-    cursor.execute("CREATE DATABASE %s"%config.MYSQL_DBNAME)
+    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."""
-    conn = MySQLdb.connect(config.MYSQL_DBHOST, config.MYSQL_DBUSER,
-        config.MYSQL_DBPASSWORD)
-#    tables = None
+    kwargs = connection_dict(config)
+    conn = MySQLdb.connect(**kwargs)
     try:
         try:
-            conn.select_db(config.MYSQL_DBNAME)
-#            cursor = conn.cursor()
-#            cursor.execute("SHOW TABLES")
-#            tables = cursor.fetchall()
-#            if __debug__:
-#                print >>hyperdb.DEBUG, "tables %s"%(tables,)
+            conn.select_db(config.RDBMS_NAME)
         except MySQLdb.OperationalError:
-            if __debug__:
-                print >>hyperdb.DEBUG, "no database '%s'"%config.MYSQL_DBNAME
             return 0
     finally:
         conn.close()
-    if __debug__:
-        print >>hyperdb.DEBUG, "database '%s' exists"%config.MYSQL_DBNAME
     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.
@@ -112,7 +117,7 @@ class Database(Database):
     #mysql_backend = 'BDB'
 
     hyperdb_to_sql_datatypes = {
-        hyperdb.String : 'VARCHAR(255)',
+        hyperdb.String : 'TEXT',
         hyperdb.Date   : 'DATETIME',
         hyperdb.Link   : 'INTEGER',
         hyperdb.Interval  : 'VARCHAR(255)',
@@ -126,23 +131,25 @@ class Database(Database):
         # no fractional seconds for MySQL
         hyperdb.Date   : lambda x: x.formal(sep=' '),
         hyperdb.Link   : int,
-        hyperdb.Interval  : lambda x: x.serialise(),
+        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):
-        db = getattr(self.config, 'MYSQL_DATABASE')
+        kwargs = connection_dict(self.config, 'db')
+        logging.getLogger('hyperdb').info('open database %r'%(kwargs['db'],))
         try:
-            conn = MySQLdb.connect(*db)
+            conn = MySQLdb.connect(**kwargs)
         except MySQLdb.OperationalError, message:
             raise DatabaseError, message
         cursor = conn.cursor()
         cursor.execute("SET AUTOCOMMIT=0")
-        cursor.execute("BEGIN")
+        cursor.execute("START TRANSACTION")
         return (conn, cursor)
-    
+
     def open_connection(self):
         # make sure the database actually exists
         if not db_exists(self.config):
@@ -159,38 +166,58 @@ class Database(Database):
             if message[0] != ER.NO_SUCH_TABLE:
                 raise DatabaseError, message
             self.init_dbschema()
-            self.sql("CREATE TABLE schema (schema TEXT) TYPE=%s"%
+            self.sql("CREATE TABLE `schema` (`schema` TEXT) ENGINE=%s"%
                 self.mysql_backend)
-            self.cursor.execute('''CREATE TABLE ids (name VARCHAR(255),
-                num INTEGER) TYPE=%s'''%self.mysql_backend)
-            self.cursor.execute('create index ids_name_idx on ids(name)')
+            self.sql('''CREATE TABLE ids (name VARCHAR(255),
+                num INTEGER) ENGINE=%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.cursor.execute('''CREATE TABLE otks (otk_key VARCHAR(255),
-            otk_value VARCHAR(255), otk_time FLOAT(20))
-            TYPE=%s'''%self.mysql_backend)
-        self.cursor.execute('CREATE INDEX otks_key_idx ON otks(otk_key)')
+        self.sql('''CREATE TABLE otks (otk_key VARCHAR(255),
+            otk_value TEXT, otk_time FLOAT(20))
+            ENGINE=%s'''%self.mysql_backend)
+        self.sql('CREATE INDEX otks_key_idx ON otks(otk_key)')
 
         # Sessions store
-        self.cursor.execute('''CREATE TABLE sessions (
-            session_key VARCHAR(255), session_time FLOAT(20),
-            session_value VARCHAR(255)) TYPE=%s'''%self.mysql_backend)
-        self.cursor.execute('''CREATE INDEX sessions_key_idx ON
+        self.sql('''CREATE TABLE sessions (session_key VARCHAR(255),
+            session_time FLOAT(20), session_value TEXT)
+            ENGINE=%s'''%self.mysql_backend)
+        self.sql('''CREATE INDEX sessions_key_idx ON
             sessions(session_key)''')
 
         # full-text indexing store
-        self.cursor.execute('''CREATE TABLE __textids (_class VARCHAR(255),
+        self.sql('''CREATE TABLE __textids (_class VARCHAR(255),
             _itemid VARCHAR(255), _prop VARCHAR(255), _textid INT)
-            TYPE=%s'''%self.mysql_backend)
-        self.cursor.execute('''CREATE TABLE __words (_word VARCHAR(30),
-            _textid INT) TYPE=%s'''%self.mysql_backend)
-        self.cursor.execute('CREATE INDEX words_word_ids ON __words(_word)')
+            ENGINE=%s'''%self.mysql_backend)
+        self.sql('''CREATE TABLE __words (_word VARCHAR(30),
+            _textid INT) ENGINE=%s'''%self.mysql_backend)
+        self.sql('CREATE INDEX words_word_ids ON __words(_word)')
+        self.sql('CREATE INDEX words_by_id ON __words (_textid)')
+        self.sql('CREATE UNIQUE INDEX __textids_by_props ON '
+                 '__textids (_class, _itemid, _prop)')
         sql = 'insert into ids (name, num) values (%s,%s)'%(self.arg, self.arg)
-        self.cursor.execute(sql, ('__textids', 1))
+        self.sql(sql, ('__textids', 1))
 
-    def add_actor_column(self):
+    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():
@@ -198,8 +225,6 @@ class Database(Database):
             properties = klass.getprops()
             old_spec = self.database_schema['tables'][cn]
 
-            execute = self.cursor.execute
-
             # figure the non-Multilink properties to copy over
             propnames = ['activity', 'creation', 'creator']
 
@@ -215,25 +240,21 @@ class Database(Database):
                 if properties.has_key(name):
                     # grabe the current values
                     sql = 'select linkid, nodeid from %s'%tn
-                    if __debug__:
-                        print >>hyperdb.DEBUG, 'migration', (self, sql)
-                    execute(sql)
+                    self.sql(sql)
                     rows = self.cursor.fetchall()
 
                 # drop the old table
                 self.drop_multilink_table_indexes(cn, name)
                 sql = 'drop table %s'%tn
-                if __debug__:
-                    print >>hyperdb.DEBUG, 'migration', (self, sql)
-                execute(sql)
+                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 
+                    sql = '''insert into %s (linkid, nodeid) values
                         (%s, %s)'''%(tn, self.arg, self.arg)
                     for linkid, nodeid in rows:
-                        execute(sql, (int(linkid), int(nodeid)))
+                        self.sql(sql, (int(linkid), int(nodeid)))
 
             # figure the column names to fetch
             fetch = ['_%s'%name for name in propnames]
@@ -243,13 +264,13 @@ class Database(Database):
             fetch.append('__retired__')
             fetchcols = ','.join(fetch)
             sql = 'select %s from _%s'%(fetchcols, cn)
-            if __debug__:
-                print >>hyperdb.DEBUG, 'migration', (self, sql)
-            self.cursor.execute(sql)
+            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)
@@ -258,8 +279,12 @@ class Database(Database):
                     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)
@@ -267,67 +292,81 @@ class Database(Database):
                         v = date.Interval(v)
                     elif isinstance(prop, Password) and v is not None:
                         v = password.Password(encrypted=v)
-                    elif (isinstance(prop, Boolean) or 
+                    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:
-                        v = self.hyperdb_to_sql_value[prop.__class__](v)
-                    l.append(v)
+                        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
-            execute('drop table _%s'%cn)
+            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 fetch])
-            sql = 'insert into _%s (%s) values (%s)'%(cn, fetchcols, args)
-            if __debug__:
-                print >>hyperdb.DEBUG, 'migration', (self, sql)
+            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:
-                if __debug__:
-                    print >>hyperdb.DEBUG, '... data', entry
-                execute(sql, tuple(entry))
+                self.sql(sql, tuple(entry))
 
-            # now load up the old journal data
+            # 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)
-            if __debug__:
-                print >>hyperdb.DEBUG, 'migration', (self, sql)
-            execute(sql)
+            self.sql(sql)
 
+            # data conversions
             olddata = []
             for nodeid, journaldate, journaltag, action, params in \
                     self.cursor.fetchall():
-                nodeid = int(nodeid)
+                #nodeid = int(nodeid)
                 journaldate = date.Date(journaldate)
-                params = eval(params)
+                #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
-            if __debug__:
-                print >>hyperdb.DEBUG, 'migration', (self, sql)
-            execute(sql)
+            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, journaldate,
+                self.save_journal(cn, cols, nodeid, dc(journaldate),
                     journaltag, action, params)
 
             # make sure the normal schema update code doesn't try to
-            # change things 
+            # 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)
 
@@ -338,17 +377,13 @@ class Database(Database):
         return self.cursor.fetchall()
 
     def sql_index_exists(self, table_name, index_name):
-        self.cursor.execute('show index from %s'%table_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 save_dbschema(self, schema):
-        s = repr(self.database_schema)
-        self.sql('INSERT INTO schema VALUES (%s)', (s,))
-    
-    def create_class_table(self, spec):
+    def create_class_table(self, spec, create_sequence=1):
         cols, mls = self.determine_columns(spec.properties.items())
 
         # add on our special columns
@@ -357,15 +392,55 @@ class Database(Database):
 
         # create the base table
         scols = ','.join(['%s %s'%x for x in cols])
-        sql = 'create table _%s (%s) type=%s'%(spec.classname, scols,
+        sql = 'create table _%s (%s) ENGINE=%s'%(spec.classname, scols,
             self.mysql_backend)
-        if __debug__:
-            print >>hyperdb.DEBUG, 'create_class', (self, sql)
-        self.cursor.execute(sql)
+        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 add_class_key_required_unique_constraint(self, cn, key):
+        # mysql requires sizes on TEXT indexes
+        prop = self.classes[cn].getprops()[key]
+        if isinstance(prop, String):
+            sql = '''create unique index _%s_key_retired_idx
+                on _%s(__retired__, _%s(255))'''%(cn, cn, key)
+        else:
+            sql = '''create unique index _%s_key_retired_idx
+                on _%s(__retired__, _%s)'''%(cn, cn, key)
+        self.sql(sql)
+
+    def create_class_table_key_index(self, cn, key):
+        # mysql requires sizes on TEXT indexes
+        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]
@@ -377,21 +452,20 @@ class Database(Database):
             if not self.sql_index_exists(table_name, index_name):
                 continue
             index_sql = 'drop index %s on %s'%(index_name, table_name)
-            if __debug__:
-                print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
-            self.cursor.execute(index_sql)
+            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 timestamp, tag varchar(255),
-            action varchar(255), params varchar(255)) type=%s'''%(
+            nodeid integer, date datetime, tag varchar(255),
+            action varchar(255), params text) ENGINE=%s'''%(
             spec.classname, self.mysql_backend)
-        if __debug__:
-            print >>hyperdb.DEBUG, 'create_journal_table', (self, sql)
-        self.cursor.execute(sql)
+        self.sql(sql)
         self.create_journal_table_indexes(spec)
 
     def drop_journal_table_indexes(self, classname):
@@ -399,17 +473,13 @@ class Database(Database):
         if not self.sql_index_exists('%s__journal'%classname, index_name):
             return
         index_sql = 'drop index %s on %s__journal'%(index_name, classname)
-        if __debug__:
-            print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
-        self.cursor.execute(index_sql)
+        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,
+            nodeid VARCHAR(255)) ENGINE=%s'''%(spec.classname, ml,
                 self.mysql_backend)
-        if __debug__:
-          print >>hyperdb.DEBUG, 'create_class', (self, sql)
-        self.cursor.execute(sql)
+        self.sql(sql)
         self.create_multilink_table_indexes(spec, ml)
 
     def drop_multilink_table_indexes(self, classname, ml):
@@ -421,10 +491,8 @@ class Database(Database):
         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)
-            if __debug__:
-                print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
-            self.cursor.execute(index_sql)
+            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
@@ -432,27 +500,21 @@ class Database(Database):
         if not self.sql_index_exists(table_name, index_name):
             return
         sql = 'drop index %s on %s'%(index_name, table_name)
-        if __debug__:
-            print >>hyperdb.DEBUG, 'drop_index', (self, sql)
-        self.cursor.execute(sql)
+        self.sql(sql)
 
     # 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
-        if __debug__:
-            print >>hyperdb.DEBUG, 'newid', (self, sql, classname)
-        self.cursor.execute(sql, (classname, ))
+        # 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)
-        if __debug__:
-            print >>hyperdb.DEBUG, 'newid', (self, sql, vals)
-        self.cursor.execute(sql, vals)
+        self.sql(sql, vals)
 
         # return as string
         return str(newid)
@@ -464,228 +526,53 @@ class Database(Database):
         '''
         sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg)
         vals = (int(setid)+1, classname)
-        if __debug__:
-            print >>hyperdb.DEBUG, 'setid', (self, sql, vals)
-        self.cursor.execute(sql, vals)
+        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)
-        if __debug__:
-            print >>hyperdb.DEBUG, 'create_class', (self, sql, vals)
-        self.cursor.execute(sql, vals)
+        self.sql(sql, vals)
 
-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
+    def sql_commit(self, fail_ok=False):
+        ''' Actually commit to the database.
+        '''
+        logging.getLogger('hyperdb').info('commit')
+
+        # MySQL commits don't seem to ever fail, the latest update winning.
+        # makes you wonder why they have transactions...
+        self.conn.commit()
 
-        "filterspec" is {propname: value(s)}
+        # open a new cursor for subsequent work
+        self.cursor = self.conn.cursor()
 
-        "sort" and "group" are (dir, prop) where dir is '+', '-' or None
-        and prop is a prop name or None
+        # make sure we're in a new transaction and not autocommitting
+        self.sql("SET AUTOCOMMIT=0")
+        self.sql("START TRANSACTION")
 
-        "search_matches" is {nodeid: marker}
+    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
 
-        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.
+class MysqlClass:
+    def _subselect(self, classname, multilink_table):
+        ''' "I can't believe it's not a toy RDBMS"
+           see, even toy RDBMSes like gadfly and sqlite can do sub-selects...
         '''
-        # just don't bother if the full-text search matched diddly
-        if search_matches == {}:
-            return []
-
-        cn = self.classname
-
-        timezone = self.db.getUserTimezone()
-        
-        # figure the WHERE clause from the filterspec
-        props = self.getprops()
-        frum = ['_'+cn]
-        where = []
-        args = []
-        a = self.db.arg
-        for k, v in filterspec.items():
-            propclass = props[k]
-            # now do other where clause stuff
-            if isinstance(propclass, Multilink):
-                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('id not in (%s)'%s)
-                elif isinstance(v, type([])):
-                    frum.append(tn)
-                    s = ','.join([a for x in v])
-                    where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
-                    args = args + v
-                else:
-                    frum.append(tn)
-                    where.append('id=%s.nodeid and %s.linkid=%s'%(tn, tn, a))
-                    args.append(v)
-            elif k == 'id':
-                if isinstance(v, type([])):
-                    s = ','.join([a for x in v])
-                    where.append('%s in (%s)'%(k, s))
-                    args = args + v
-                else:
-                    where.append('%s=%s'%(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(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v]))
-                # note: args are embedded in the query string now
-            elif isinstance(propclass, Link):
-                if isinstance(v, type([])):
-                    if '-1' in v:
-                        v = v[:]
-                        v.remove('-1')
-                        xtra = ' or _%s is NULL'%k
-                    else:
-                        xtra = ''
-                    if v:
-                        s = ','.join([a for x in v])
-                        where.append('(_%s in (%s)%s)'%(k, s, xtra))
-                        args = args + v
-                    else:
-                        where.append('_%s is NULL'%k)
-                else:
-                    if v == '-1':
-                        v = None
-                        where.append('_%s is NULL'%k)
-                    else:
-                        where.append('_%s=%s'%(k, a))
-                        args.append(v)
-            elif isinstance(propclass, Date):
-                if isinstance(v, type([])):
-                    s = ','.join([a for x in v])
-                    where.append('_%s in (%s)'%(k, s))
-                    args = args + [date.Date(x).serialise() for x in v]
-                else:
-                    try:
-                        # Try to filter on range of dates
-                        date_rng = Range(v, date.Date, offset=timezone)
-                        if (date_rng.from_value):
-                            where.append('_%s >= %s'%(k, a))                            
-                            args.append(date_rng.from_value.serialise())
-                        if (date_rng.to_value):
-                            where.append('_%s <= %s'%(k, a))
-                            args.append(date_rng.to_value.serialise())
-                    except ValueError:
-                        # If range creation fails - ignore that search parameter
-                        pass                        
-            elif isinstance(propclass, Interval):
-                if isinstance(v, type([])):
-                    s = ','.join([a for x in v])
-                    where.append('_%s in (%s)'%(k, s))
-                    args = args + [date.Interval(x).serialise() 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'%(k, a))
-                            args.append(date_rng.from_value.serialise())
-                        if (date_rng.to_value):
-                            where.append('_%s <= %s'%(k, a))
-                            args.append(date_rng.to_value.serialise())
-                    except ValueError:
-                        # If range creation fails - ignore that search parameter
-                        pass                        
-                    #where.append('_%s=%s'%(k, a))
-                    #args.append(date.Interval(v).serialise())
-            else:
-                if isinstance(v, type([])):
-                    s = ','.join([a for x in v])
-                    where.append('_%s in (%s)'%(k, s))
-                    args = args + v
-                else:
-                    where.append('_%s=%s'%(k, a))
-                    args.append(v)
-
-        # don't match retired nodes
-        where.append('__retired__ <> 1')
-
-        # 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('id in (%s)'%s)
-            args = args + v
-
-        # "grouping" is just the first-order sorting in the SQL fetch
-        # can modify it...)
-        orderby = []
-        ordercols = []
-        if group[0] is not None and group[1] is not None:
-            if group[0] != '-':
-                orderby.append('_'+group[1])
-                ordercols.append('_'+group[1])
-            else:
-                orderby.append('_'+group[1]+' desc')
-                ordercols.append('_'+group[1])
-
-        # now add in the sorting
-        group = ''
-        if sort[0] is not None and sort[1] is not None:
-            direction, colname = sort
-            if direction != '-':
-                if colname == 'id':
-                    orderby.append(colname)
-                else:
-                    orderby.append('_'+colname)
-                    ordercols.append('_'+colname)
-            else:
-                if colname == 'id':
-                    orderby.append(colname+' desc')
-                    ordercols.append(colname)
-                else:
-                    orderby.append('_'+colname+' desc')
-                    ordercols.append('_'+colname)
-
-        # construct the SQL
-        frum = ','.join(frum)
-        if where:
-            where = ' where ' + (' and '.join(where))
-        else:
-            where = ''
-        cols = ['id']
-        if orderby:
-            cols = cols + ordercols
-            order = ' order by %s'%(','.join(orderby))
-        else:
-            order = ''
-        cols = ','.join(cols)
-        sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order)
-        args = tuple(args)
-        if __debug__:
-            print >>hyperdb.DEBUG, 'filter', (self, sql, args)
-        self.db.cursor.execute(sql, args)
-        l = self.db.cursor.fetchall()
-
-        # return the IDs (the first column)
-        # XXX numeric ids
-        return [str(row[0]) for row in l]
+        self.db.sql('select nodeid from %s'%multilink_table)
+        s = ','.join([x[0] for x in self.db.sql_fetchall()])
+        return '_%s.id not in (%s)'%(classname, s)
 
 class Class(MysqlClass, rdbms_common.Class):
     pass
@@ -694,4 +581,4 @@ class IssueClass(MysqlClass, rdbms_common.IssueClass):
 class FileClass(MysqlClass, rdbms_common.FileClass):
     pass
 
-#vim: set et
+# vim: set et sts=4 sw=4 :
index 5e6214c3e72688fab0278b54c908408019aa09b5..3298389303473fe45865be789b69efc1e10ddecb 100644 (file)
@@ -1,3 +1,4 @@
+#$Id: back_postgresql.py,v 1.44 2008-08-07 05:50:03 richard Exp $
 #
 # Copyright (c) 2003 Martynas Sklyzmantas, Andrey Lebedev <andrey@micro.lt>
 #
 __docformat__ = 'restructuredtext'
 
 import os, shutil, popen2, time
-import psycopg
+try:
+    import psycopg
+    from psycopg import QuotedString
+    from psycopg import ProgrammingError
+except:
+    from psycopg2 import psycopg1 as psycopg
+    from psycopg2.extensions import QuotedString
+    from psycopg2.psycopg1 import ProgrammingError
+import logging
 
 from roundup import hyperdb, date
 from roundup.backends import rdbms_common
+from roundup.backends import sessions_rdbms
+
+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"""
-    if __debug__:
-        print >> hyperdb.DEBUG, '+++ create database +++'
-    name = config.POSTGRESQL_DATABASE['database']
-    n = 0
-    while n < 10:
-        cout,cin = popen2.popen4('createdb %s'%name)
-        cin.close()
-        response = cout.read().split('\n')[0]
-        if response.find('FATAL') != -1:
-            raise RuntimeError, response
-        elif response.find('ERROR') != -1:
-            if not response.find('is being accessed by other users') != -1:
-                raise RuntimeError, response
-            if __debug__:
-                print >> hyperdb.DEBUG, '+++ SLEEPING +++'
-            time.sleep(1)
-            n += 1
-            continue
-        return
-    raise RuntimeError, '10 attempts to create database failed'
+    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"""
-    if __debug__:
-        print >> hyperdb.DEBUG, '+++ nuke database +++'
-    name = config.POSTGRESQL_DATABASE['database']
-    n = 0
+    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)
-    while n < 10:
-        cout,cin = popen2.popen4('dropdb %s'%name)
-        cin.close()
-        response = cout.read().split('\n')[0]
-        if response.endswith('does not exist') and fail_ok:
-            return
-        elif response.find('FATAL') != -1:
+
+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.
+
+    If there is a concurrent update, retry the command.
+    '''
+    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 not response.find('is being accessed by other users') != -1:
-                raise RuntimeError, response
-            if __debug__:
-                print >> hyperdb.DEBUG, '+++ SLEEPING +++'
-            time.sleep(1)
-            n += 1
-            continue
-        return
-    raise RuntimeError, '10 attempts to nuke database failed'
+        else:
+            msgs = [
+                'is being accessed by other users',
+                'could not serialize access due to concurrent update',
+            ]
+            can_retry = 0
+            for msg in msgs:
+                if response.find(msg) == -1:
+                    can_retry = 1
+            if can_retry:
+                time.sleep(1)
+                return 0
+            raise RuntimeError, response
+    return 1
 
 def db_exists(config):
     """Check if database already exists"""
-    db = getattr(config, 'POSTGRESQL_DATABASE')
+    db = connection_dict(config, 'database')
     try:
         conn = psycopg.connect(**db)
         conn.close()
-        if __debug__:
-            print >> hyperdb.DEBUG, '+++ database exists +++'
         return 1
     except:
-        if __debug__:
-            print >> hyperdb.DEBUG, '+++ no database +++'
         return 0
 
+class Sessions(sessions_rdbms.Sessions):
+    def set(self, *args, **kwargs):
+        try:
+            sessions_rdbms.Sessions.set(self, *args, **kwargs)
+        except ProgrammingError, err:
+            response = str(err).split('\n')[0]
+            if -1 != response.find('ERROR') and \
+               -1 != response.find('could not serialize access due to concurrent update'):
+                # another client just updated, and we're running on
+                # serializable isolation.
+                # see http://www.postgresql.org/docs/7.4/interactive/transaction-iso.html
+                self.db.rollback()
+
 class Database(rdbms_common.Database):
     arg = '%s'
 
+    # used by some code to switch styles of query
+    implements_intersect = 1
+
+    def getSessionManager(self):
+        return Sessions(self)
+
     def sql_open_connection(self):
-        db = getattr(self.config, 'POSTGRESQL_DATABASE')
+        db = connection_dict(self.config, 'database')
+        logging.getLogger('hyperdb').info('open database %r'%db['database'])
         try:
             conn = psycopg.connect(**db)
         except psycopg.OperationalError, message:
@@ -96,14 +145,13 @@ class Database(rdbms_common.Database):
         if not db_exists(self.config):
             db_create(self.config)
 
-        if __debug__:
-            print >>hyperdb.DEBUG, '+++ open database connection +++'
-
         self.conn, self.cursor = self.sql_open_connection()
 
         try:
             self.load_dbschema()
-        except:
+        except psycopg.ProgrammingError, message:
+            if str(message).find('schema') == -1:
+                raise
             self.rollback()
             self.init_dbschema()
             self.sql("CREATE TABLE schema (schema TEXT)")
@@ -113,103 +161,128 @@ class Database(rdbms_common.Database):
 
     def create_version_2_tables(self):
         # OTK store
-        self.cursor.execute('''CREATE TABLE otks (otk_key VARCHAR(255),
-            otk_value VARCHAR(255), otk_time FLOAT(20))''')
-        self.cursor.execute('CREATE INDEX otks_key_idx ON otks(otk_key)')
+        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.cursor.execute('''CREATE TABLE sessions (
-            session_key VARCHAR(255), session_time FLOAT(20),
-            session_value VARCHAR(255))''')
-        self.cursor.execute('''CREATE INDEX sessions_key_idx ON
+        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.cursor.execute('CREATE SEQUENCE ___textids_ids')
-        self.cursor.execute('''CREATE TABLE __textids (
+        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.cursor.execute('''CREATE TABLE __words (_word VARCHAR(30), 
+        self.sql('''CREATE TABLE __words (_word VARCHAR(30),
             _textid integer)''')
-        self.cursor.execute('CREATE INDEX words_word_idx ON __words(_word)')
+        self.sql('CREATE INDEX words_word_idx ON __words(_word)')
+        self.sql('CREATE INDEX words_by_id ON __words (_textid)')
+        self.sql('CREATE UNIQUE INDEX __textids_by_props ON '
+                 '__textids (_class, _itemid, _prop)')
+
+    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.cursor.execute('ALTER TABLE _%s add __actor '
-                'VARCHAR(255)'%name)
+            self.sql('ALTER TABLE _%s add __actor VARCHAR(255)'%name)
 
     def __repr__(self):
         return '<roundpsycopgsql 0x%x>' % id(self)
 
+    def sql_commit(self, fail_ok=False):
+        ''' Actually commit to the database.
+        '''
+        logging.getLogger('hyperdb').info('commit')
+
+        try:
+            self.conn.commit()
+        except psycopg.ProgrammingError, message:
+            # we've been instructed that this commit is allowed to fail
+            if fail_ok and str(message).endswith('could not serialize '
+                    'access due to concurrent update'):
+                logging.getLogger('hyperdb').info('commit FAILED, but fail_ok')
+            else:
+                raise
+
+        # open a new cursor for subsequent work
+        self.cursor = self.conn.cursor()
+
     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]
+        return str(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.cursor.execute(sql, (table_name, index_name))
+        self.sql(sql, (table_name, index_name))
         return self.cursor.fetchone()[0]
 
-    def create_class_table(self, spec):
-        sql = 'CREATE SEQUENCE _%s_ids'%spec.classname
-        if __debug__:
-            print >>hyperdb.DEBUG, 'create_class_table', (self, sql)
-        self.cursor.execute(sql)
+    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
-        if __debug__:
-            print >>hyperdb.DEBUG, 'drop_class', (self, sql)
-        self.cursor.execute(sql)
+        self.sql(sql)
 
         sql = 'drop sequence _%s_ids'%cn
-        if __debug__:
-            print >>hyperdb.DEBUG, 'drop_class', (self, sql)
-        self.cursor.execute(sql)
-
-    def create_journal_table(self, spec):
-        cols = ',' . join(['"%s" VARCHAR(255)'%x
-          for x in 'nodeid date tag action params' . split()])
-        sql  = 'CREATE TABLE "%s__journal" (%s)'%(spec.classname, cols)
-        if __debug__:
-            print >>hyperdb.DEBUG, 'create_journal_table', (self, sql)
-        self.cursor.execute(sql)
-        self.create_journal_table_indexes(spec)
-
-    def create_multilink_table(self, spec, ml):
-        sql = '''CREATE TABLE "%s_%s" (linkid VARCHAR(255),
-            nodeid VARCHAR(255))'''%(spec.classname, ml)
-
-        if __debug__:
-            print >>hyperdb.DEBUG, 'create_class', (self, sql)
-
-        self.cursor.execute(sql)
-        self.create_multilink_table_indexes(spec, ml)
+        self.sql(sql)
 
     def newid(self, classname):
         sql = "select nextval('_%s_ids') from dual"%classname
-        if __debug__:
-            print >>hyperdb.DEBUG, 'setid', (self, sql)
-        self.cursor.execute(sql)
-        return self.cursor.fetchone()[0]
+        self.sql(sql)
+        return str(self.cursor.fetchone()[0])
 
     def setid(self, classname, setid):
         sql = "select setval('_%s_ids', %s) from dual"%(classname, int(setid))
-        if __debug__:
-            print >>hyperdb.DEBUG, 'setid', (self, sql)
-        self.cursor.execute(sql)
+        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 PostgresqlClass:
+    order_by_null_values = '(%s is not NULL)'
 
-class Class(rdbms_common.Class):
+class Class(PostgresqlClass, rdbms_common.Class):
     pass
-class IssueClass(rdbms_common.IssueClass):
+class IssueClass(PostgresqlClass, rdbms_common.IssueClass):
     pass
-class FileClass(rdbms_common.FileClass):
+class FileClass(PostgresqlClass, rdbms_common.FileClass):
     pass
 
+# vim: set et sts=4 sw=4 :
index f624fa0720492c8679ae58500f06b2bc7aa4a9d8..cdc3209a2688fe21221894e10651d6cbf9e9e883 100644 (file)
@@ -1,4 +1,4 @@
-# $Id: back_sqlite.py,v 1.24 2004-04-07 01:12:26 richard Exp $
+# $Id: back_sqlite.py,v 1.51 2007-06-21 07:35:50 schlatterbeck Exp $
 '''Implements a backend for SQLite.
 
 See https://pysqlite.sourceforge.net/ for pysqlite info
@@ -9,16 +9,41 @@ for the columns, but sqlite IGNORES these specifications.
 '''
 __docformat__ = 'restructuredtext'
 
-import os, base64, marshal
+import os, base64, marshal, shutil, time, logging
 
 from roundup import hyperdb, date, password
-from roundup.backends import locking
 from roundup.backends import rdbms_common
-import sqlite
+sqlite_version = None
+try:
+    import sqlite
+    sqlite_version = 1
+except ImportError:
+    try:
+        from pysqlite2 import dbapi2 as sqlite
+        if sqlite.version_info < (2,1,0):
+            raise ValueError('pysqlite2 minimum version is 2.1.0+ '
+                '- %s found'%sqlite.version)
+        sqlite_version = 2
+    except ImportError:
+        import sqlite3 as sqlite
+        sqlite_version = 3
+
+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'
+    if sqlite_version in (2,3):
+        arg = '?'
+    else:
+        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)',
@@ -32,37 +57,64 @@ class Database(rdbms_common.Database):
         hyperdb.String : str,
         hyperdb.Date   : lambda x: x.serialise(),
         hyperdb.Link   : int,
-        hyperdb.Interval  : lambda x: x.serialise(),
+        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.String : lambda x: isinstance(x, unicode) and x.encode('utf8') or str(x),
         hyperdb.Date   : lambda x: date.Date(str(x)),
-#        hyperdb.Link   : int,      # XXX numeric ids
-        hyperdb.Link   : str,
+        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')
-        conn = sqlite.connect(db=db)
+        logging.getLogger('hyperdb').info('open database %r'%db)
+        # set a 30 second timeout (extraordinarily generous) for handling
+        # locked database
+        if sqlite_version == 1:
+            conn = sqlite.connect(db=db)
+            conn.db.sqlite_busy_handler(self.sqlite_busy_handler)
+        else:
+            conn = sqlite.connect(db, timeout=30)
+            conn.row_factory = sqlite.Row
         cursor = conn.cursor()
         return (conn, cursor)
 
     def open_connection(self):
         # ensure files are group readable and writable
-        os.umask(0002)
-
-        # lock the database
-        lockfilenm = os.path.join(self.dir, 'lock')
-        self.lockfile = locking.acquire_lock(lockfilenm)
-        self.lockfile.write(str(os.getpid()))
-        self.lockfile.flush()
+        os.umask(self.config.UMASK)
 
         (self.conn, self.cursor) = self.sql_open_connection()
 
@@ -72,40 +124,47 @@ class Database(rdbms_common.Database):
             if str(error) != 'no such table: schema':
                 raise
             self.init_dbschema()
-            self.cursor.execute('create table schema (schema varchar)')
-            self.cursor.execute('create table ids (name varchar, num integer)')
-            self.cursor.execute('create index ids_name_idx on ids(name)')
+            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.cursor.execute('create table otks (otk_key varchar, '
+        self.sql('create table otks (otk_key varchar, '
             'otk_value varchar, otk_time integer)')
-        self.cursor.execute('create index otks_key_idx on otks(otk_key)')
-        self.cursor.execute('create table sessions (session_key varchar, '
+        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.cursor.execute('create index sessions_key_idx on '
+        self.sql('create index sessions_key_idx on '
                 'sessions(session_key)')
 
         # full-text indexing store
-        self.cursor.execute('CREATE TABLE __textids (_class varchar, '
+        self.sql('CREATE TABLE __textids (_class varchar, '
             '_itemid varchar, _prop varchar, _textid integer primary key) ')
-        self.cursor.execute('CREATE TABLE __words (_word varchar, '
+        self.sql('CREATE TABLE __words (_word varchar, '
             '_textid integer)')
-        self.cursor.execute('CREATE INDEX words_word_ids ON __words(_word)')
+        self.sql('CREATE INDEX words_word_ids ON __words(_word)')
+        self.sql('CREATE INDEX words_by_id ON __words (_textid)')
+        self.sql('CREATE UNIQUE INDEX __textids_by_props ON '
+                 '__textids (_class, _itemid, _prop)')
         sql = 'insert into ids (name, num) values (%s,%s)'%(self.arg, self.arg)
-        self.cursor.execute(sql, ('__textids', 1))
+        self.sql(sql, ('__textids', 1))
 
-    def add_actor_column(self):
+    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_actor=1)
+                self.update_class(spec, dbspec, force=1, adding_v2=1)
                 # we've updated - don't try again
                 tables[classname] = spec.schema()
 
-    def update_class(self, spec, old_spec, force=0, adding_actor=0):
+    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.
 
@@ -122,8 +181,7 @@ class Database(rdbms_common.Database):
             # no changes
             return 0
 
-        if __debug__:
-            print >>hyperdb.DEBUG, 'update_class FIRING for', spec.classname
+        logging.getLogger('hyperdb').info('update_class %s'%spec.classname)
 
         # detect multilinks that have been removed, and drop their table
         old_has = {}
@@ -133,15 +191,13 @@ class Database(rdbms_common.Database):
                 continue
             # it's a multilink, and it's been removed - drop the old
             # table. First drop indexes.
-            self.drop_multilink_table_indexes(spec.classname, ml)
+            self.drop_multilink_table_indexes(spec.classname, name)
             sql = 'drop table %s_%s'%(spec.classname, prop)
-            if __debug__:
-                print >>hyperdb.DEBUG, 'update_class', (self, sql)
-            self.cursor.execute(sql)
+            self.sql(sql)
         old_has = old_has.has_key
 
         # now figure how we populate the new table
-        if adding_actor:
+        if adding_v2:
             fetch = ['_activity', '_creation', '_creator']
         else:
             fetch = ['_actor', '_activity', '_creation', '_creator']
@@ -156,24 +212,20 @@ class Database(rdbms_common.Database):
                     tn = '%s_%s'%(spec.classname, propname)
                     # grabe the current values
                     sql = 'select linkid, nodeid from %s'%tn
-                    if __debug__:
-                        print >>hyperdb.DEBUG, 'update_class', (self, sql)
-                    self.cursor.execute(sql)
+                    self.sql(sql)
                     rows = self.cursor.fetchall()
 
                     # drop the old table
                     self.drop_multilink_table_indexes(spec.classname, propname)
                     sql = 'drop table %s'%tn
-                    if __debug__:
-                        print >>hyperdb.DEBUG, 'migration', (self, sql)
-                    self.cursor.execute(sql)
+                    self.sql(sql)
 
                     # re-create and populate the new table
                     self.create_multilink_table(spec, propname)
-                    sql = '''insert into %s (linkid, nodeid) values 
+                    sql = '''insert into %s (linkid, nodeid) values
                         (%s, %s)'''%(tn, self.arg, self.arg)
                     for linkid, nodeid in rows:
-                        self.cursor.execute(sql, (int(linkid), int(nodeid)))
+                        self.sql(sql, (int(linkid), int(nodeid)))
             elif old_has(propname):
                 # we copy this col over from the old table
                 fetch.append('_'+propname)
@@ -184,31 +236,62 @@ class Database(rdbms_common.Database):
         fetchcols = ','.join(fetch)
         cn = spec.classname
         sql = 'select %s from _%s'%(fetchcols, cn)
-        if __debug__:
-            print >>hyperdb.DEBUG, 'update_class', (self, sql)
-        self.cursor.execute(sql)
+        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
-        if __debug__:
-            print >>hyperdb.DEBUG, 'update_class "drop table _%s"'%cn
-        self.cursor.execute('drop table _%s'%cn)
+        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 fetch])
-            sql = 'insert into _%s (%s) values (%s)'%(cn, fetchcols, args)
-            if __debug__:
-                print >>hyperdb.DEBUG, 'update_class', (self, sql, olddata[0])
+            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:
-                self.cursor.execute(sql, tuple(entry))
+                d = []
+                for name in inscols:
+                    # generate the new value for the Interval int column
+                    if name.endswith('_int__'):
+                        name = name[2:-6]
+                        if sqlite_version in (2,3):
+                            try:
+                                v = hyperdb.Interval(entry[name]).as_seconds()
+                            except IndexError:
+                                v = None
+                        elif entry.has_key(name):
+                            v = hyperdb.Interval(entry[name]).as_seconds()
+                        else:
+                            v = None
+                    elif sqlite_version in (2,3):
+                        try:
+                            v = entry[name]
+                        except IndexError:
+                            v = None
+                    elif (sqlite_version == 1 and entry.has_key(name)):
+                        v = entry[name]
+                    else:
+                        v = None
+                    d.append(v)
+                self.sql(sql, tuple(d))
 
         return 1
 
@@ -217,17 +300,10 @@ class Database(rdbms_common.Database):
             connection.
         '''
         try:
-            try:
-                self.conn.close()
-            except sqlite.ProgrammingError, value:
-                if str(value) != 'close failed - Connection is closed.':
-                    raise
-        finally:
-            # always release the lock
-            if self.lockfile is not None:
-                locking.release_lock(self.lockfile)
-                self.lockfile.close()
-                self.lockfile = None
+            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
@@ -242,7 +318,7 @@ class Database(rdbms_common.Database):
     def __repr__(self):
         return '<roundlite 0x%x>'%id(self)
 
-    def sql_commit(self):
+    def sql_commit(self, fail_ok=False):
         ''' Actually commit to the database.
 
             Ignore errors if there's nothing to commit.
@@ -252,9 +328,11 @@ class Database(rdbms_common.Database):
         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.cursor.execute('pragma index_list(%s)'%table_name)
+        self.sql('pragma index_list(%s)'%table_name)
         for entry in self.cursor.fetchall():
             if entry[1] == index_name:
                 return 1
@@ -266,17 +344,13 @@ class Database(rdbms_common.Database):
         '''
         # get the next ID
         sql = 'select num from ids where name=%s'%self.arg
-        if __debug__:
-            print >>hyperdb.DEBUG, 'newid', (self, sql, classname)
-        self.cursor.execute(sql, (classname, ))
+        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)
-        if __debug__:
-            print >>hyperdb.DEBUG, 'newid', (self, sql, vals)
-        self.cursor.execute(sql, vals)
+        self.sql(sql, vals)
 
         # return as string
         return str(newid)
@@ -284,21 +358,32 @@ class Database(rdbms_common.Database):
     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.
+        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)
-        if __debug__:
-            print >>hyperdb.DEBUG, 'setid', (self, sql, vals)
-        self.cursor.execute(sql, vals)
+        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)'
+        sql = 'insert into ids (name, num) values (%s, %s)'%(self.arg, self.arg)
         vals = (spec.classname, 1)
-        if __debug__:
-            print >>hyperdb.DEBUG, 'create_class', (self, sql, vals)
-        self.cursor.execute(sql, vals)
+        self.sql(sql, vals)
+
+    if sqlite_version in (2,3):
+        def load_journal(self, classname, cols, nodeid):
+            '''We need to turn the sqlite3.Row into a tuple so it can be
+            unpacked'''
+            l = rdbms_common.Database.load_journal(self,
+                classname, cols, nodeid)
+            cols = range(5)
+            return [[row[col] for col in cols] for row in l]
 
 class sqliteClass:
     def filter(self, search_matches, filterspec, sort=(None,None),
@@ -318,4 +403,4 @@ class IssueClass(sqliteClass, rdbms_common.IssueClass):
 class FileClass(sqliteClass, rdbms_common.FileClass):
     pass
 
-
+# vim: set et sts=4 sw=4 :
diff --git a/roundup/backends/back_tsearch2.py b/roundup/backends/back_tsearch2.py
new file mode 100644 (file)
index 0000000..0eb3992
--- /dev/null
@@ -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()
index c67a0e8eef732535bad04b12d317d1fe9693ce5e..16b8347843cfa05c434183263babf6a2ffb0c104 100644 (file)
@@ -14,8 +14,8 @@
 # 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.12 2004-03-19 04:47:59 richard Exp $
+#
+#$Id: blobfiles.py,v 1.24 2008-02-07 00:57:59 richard Exp $
 '''This module exports file storage for roundup backends.
 Files are stored into a directory hierarchy.
 '''
@@ -23,7 +23,7 @@ __docformat__ = 'restructuredtext'
 
 import os
 
-def files_in_dir(dir):       
+def files_in_dir(dir):
     if not os.path.exists(dir):
         return 0
     num_files = 0
@@ -36,34 +36,263 @@ def files_in_dir(dir):
     return num_files
 
 class FileStorage:
-    """Store files in some directory structure"""
-    def filename(self, classname, nodeid, property=None):
-        '''Determine what the filename for the given node and optionally 
-           property is.
-        '''
+    """Store files in some directory structure
+
+    Some databases do not permit the storage of arbitrary data (i.e.,
+    file content).  And, some database schema explicitly store file
+    content in the fielsystem.  In particular, if a class defines a
+    'filename' property, it is assumed that the data is stored in the
+    indicated file, outside of whatever database Roundup is otherwise
+    using.
+
+    In these situations, it is difficult to maintain the transactional
+    abstractions used elsewhere in Roundup.  In particular, if a
+    file's content is edited, but then the containing transaction is
+    not committed, we do not want to commit the edit.  Similarly, we
+    would like to guarantee that if a transaction is committed to the
+    database, then the edit has in fact taken place.
+
+    This class provides an approximation of these transactional
+    requirements.
+
+    For classes that do not have a 'filename' property, the file name
+    used to store the file's content is a deterministic function of
+    the classname and nodeid for the file.  The 'filename' function
+    computes this name.  The name will contain directories and
+    subdirectories, but, suppose, for the purposes of what follows,
+    that the filename is 'file'.
+
+    Edit Procotol
+    -------------
+    
+    When a file is created or edited, the following protocol is used:
+
+    1. The new content of the file is placed in 'file.tmp'.
+
+    2. A transaction is recored in 'self.transactions' referencing the
+       'doStoreFile' method of this class.
+
+    3. At some subsequent point, the database 'commit' function is
+       called.  This function first performs a traditional database
+       commit (for example, by issuing a SQL command to commit the
+       current transaction), and, then, runs the transactions recored
+       in 'self.transactions'.
+
+    4. The 'doStoreFile' method renames the 'file.tmp' to 'file'.
+
+    If Step 3 never occurs, but, instead, the database 'rollback'
+    method is called, then that method, after rolling back the
+    database transaction, calls 'rollbackStoreFile', which removes
+    'file.tmp'.
+
+    Race Condition
+    --------------
+
+    If two Roundup instances (say, the mail gateway and a web client,
+    or two web clients running with a multi-process server) attempt
+    edits at the same time, both will write to 'file.tmp', and the
+    results will be indeterminate.
+    
+    Crash Analysis
+    --------------
+    
+    There are several situations that may occur if a crash (whether
+    because the machine crashes, because an unhandled Python exception
+    is raised, or because the Python process is killed) occurs.
+    
+    Complexity ensues because backuping up an RDBMS is generally more
+    complex than simply copying a file.  Instead, some command is run
+    which stores a snapshot of the database in a file.  So, if you
+    back up the database to a file, and then back up the filesystem,
+    it is likely that further database transactions have occurred
+    between the point of database backup and the point of filesystem
+    backup.
+
+    For the purposes, of this analysis, we assume that the filesystem
+    backup occurred after the database backup.  Furthermore, we assume
+    that filesystem backups are atomic; i.e., the at the filesystem is
+    not being modified during the backup.
+
+    1. Neither the 'commit' nor 'rollback' methods on the database are
+       ever called.
+
+       In this case, the '.tmp' file should be ignored as the
+       transaction was not committed.
+
+    2. The 'commit' method is called.  Subsequently, the machine
+       crashes, and is restored from backups.
+
+       The most recent filesystem backup and the most recent database
+       backup are not in general from the same instant in time.
+
+       This problem means that we can never be sure after a crash if
+       the contents of a file are what we intend.  It is always
+       possible that an edit was made to the file that is not
+       reflected in the filesystem.
+
+    3. A crash occurs between the point of the database commit and the
+       call to 'doStoreFile'.
+
+       If only one of 'file' and 'file.tmp' exists, then that
+       version should be used.  However, if both 'file' and 'file.tmp'
+       exist, there is no way to know which version to use.
+
+    Reading the File
+    ----------------
+
+    When determining the content of the file, we use the following
+    algorithm:
+
+    1. If 'self.transactions' reflects an edit of the file, then use
+       'file.tmp'.
+
+       We know that an edit to the file is in process so 'file.tmp' is
+       the right choice.  If 'file.tmp' does not exist, raise an
+       exception; something has removed the content of the file while
+       we are in the process of editing it.
+
+    2. Otherwise, if 'file.tmp' exists, and 'file' does not, use
+       'file.tmp'.
+
+       We know that the file is supposed to exist because there is a
+       reference to it in the database.  Since 'file' does not exist,
+       we assume that Crash 3 occurred during the initial creation of
+       the file.
+
+    3. Otherwise, use 'file'.
+
+       If 'file.tmp' is not present, this is obviously the best we can
+       do.  This is always the right answer unless Crash 2 occurred,
+       in which case the contents of 'file' may be newer than they
+       were at the point of database backup.
+
+       If 'file.tmp' is present, we know that we are not actively
+       editing the file.  The possibilities are:
+
+       a. Crash 1 has occurred.  In this case, using 'file' is the
+          right answer, so we will have chosen correctly.
+
+       b. Crash 3 has occurred.  In this case, 'file.tmp' is the right
+          answer, so we will have chosen incorrectly.  However, 'file'
+          was at least a previously committed value.
+
+    Future Improvements
+    -------------------
+
+    One approach would be to take advantage of databases which do
+    allow the storage of arbitary date.  For example, MySQL provides
+    the HUGE BLOB datatype for storing up to 4GB of data.
+
+    Another approach would be to store a version ('v') in the actual
+    database and name files 'file.v'.  Then, the editing protocol
+    would become:
+
+    1. Generate a new version 'v', guaranteed to be different from all
+       other versions ever used by the database.  (The version need
+       not be in any particular sequence; a UUID would be fine.)
+
+    2. Store the content in 'file.v'.
+
+    3. Update the database to indicate that the version of the node is
+       'v'.
+
+    Now, if the transaction is committed, the database will refer to
+    'file.v', where the content exists.  If the transaction is rolled
+    back, or not committed, 'file.v' will never be referenced.  In the
+    event of a crash, under the assumptions above, there may be
+    'file.v' files that are not referenced by the database, but the
+    database will be consistent, so long as unreferenced 'file.v'
+    files are never removed until after the database has been backed
+    up.
+    """    
+
+    tempext = '.tmp'
+    """The suffix added to files indicating that they are uncommitted."""
+    
+    def __init__(self, umask):
+        self.umask = umask
+
+    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 
+            # 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(self.dir, 'files', classname, subdir, name)
+        return os.path.join(subdir, name)
 
-    def filename_flat(self, classname, nodeid, property=None):
-        '''Determine what the filename for the given node and optionally 
-           property is.
+    def _tempfile(self, filename):
+        """Return a temporary filename.
+
+        'filename' -- The name of the eventual destination file."""
+
+        return filename + self.tempext
+
+    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 the caller is going to create the file, return the
+        # post-commit filename.  It is the callers responsibility to
+        # add self.tempext when actually creating the file.
+        if create:
+            return filename
+
+        tempfile = self._tempfile(filename)
+
+        # If an edit to this file is in progress, then return the name
+        # of the temporary file containing the edited content.
+        for method, args in self.transactions:
+            if (method == self.doStoreFile and
+                    args == (classname, nodeid, property)):
+                # There is an edit in progress for this file.
+                if not os.path.exists(tempfile):
+                    raise IOError('content file for %s not found'%tempfile)
+                return tempfile
+
+        if os.path.exists(filename):
+            return filename
+
+        # Otherwise, if the temporary file exists, then the probable 
+        # explanation is that a crash occurred between the point that
+        # the database entry recording the creation of the file
+        # occured and the point at which the file was renamed from the
+        # temporary name to the final name.
+        if os.path.exists(tempfile):
+            try:
+                # Clean up, by performing the commit now.
+                os.rename(tempfile, filename)
+            except:
+                pass
+            # If two Roundup clients both try to rename the file
+            # at the same time, only one of them will succeed.
+            # So, tolerate such an error -- but no other.
+            if not os.path.exists(filename):
+                raise IOError('content file for %s not found'%filename)
+            return filename
+
+        # ok, try flat (very old-style)
         if property:
-            return os.path.join(self.dir, 'files', '%s%s.%s'%(classname,
+            filename = os.path.join(self.dir, 'files', '%s%s.%s'%(classname,
                 nodeid, property))
         else:
-            # roundupdb.FileClass never specified the property name, so don't 
-            # include it
-            return os.path.join(self.dir, 'files', '%s%s'%(classname,
+            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
@@ -71,39 +300,37 @@ class FileStorage:
            is being saved.
         '''
         # determine the name of the file to write to
-        name = self.filename(classname, nodeid, property)
+        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'
+        name = self._tempfile(name)
+
         # 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)))
+        # always set umask before writing to make sure we have the proper one
+        # in multi-tracker (i.e. multi-umask) or modpython scenarios
+        # the umask may have changed since last we set it.
+        os.umask(self.umask)
         open(name, 'wb').write(content)
 
     def getfile(self, classname, nodeid, property):
         '''Get the content of the file in the database.
         '''
-        # 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 = self.filename(classname, nodeid, property)
-        flat_filename = self.filename_flat(classname, nodeid, property)
-        for filename in (filename, filename+'.tmp', flat_filename):
-            if os.path.exists(filename):
-                f = open(filename, 'rb')
-                break
-        else:
-            raise IOError, 'content file not found'
-        # snarf the contents and make sure we close the file
-        content = f.read()
-        f.close()
-        return content
+
+        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.
@@ -115,15 +342,23 @@ class FileStorage:
         '''Store the file as part of a transaction commit.
         '''
         # determine the name of the file to write to
-        name = self.filename(classname, nodeid, property)
+        name = self.filename(classname, nodeid, property, 1)
+
+        # the file is currently ".tmp" - move it to its real name to commit
+        if name.endswith(self.tempext):
+            # creation
+            dstname = os.path.splitext(name)[0]
+        else:
+            # edit operation
+            dstname = name
+            name = self._tempfile(name)
 
         # 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(name):
-            os.remove(name)
+        if os.path.exists(dstname):
+            os.remove(dstname)
 
-        # the file is currently ".tmp" - move it to its real name to commit
-        os.rename(name+".tmp", name)
+        os.rename(name, dstname)
 
         # return the classname, nodeid so we reindex this content
         return (classname, nodeid)
@@ -133,7 +368,25 @@ class FileStorage:
         '''
         # determine the name of the file to delete
         name = self.filename(classname, nodeid, property)
-        if os.path.exists(name+".tmp"):
-            os.remove(name+".tmp")
+        if not name.endswith(self.tempext):
+            name += self.tempext
+        os.remove(name)
+
+    def isStoreFile(self, classname, nodeid):
+        '''See if there is actually any FileStorage for this node.
+           Is there a better way than using self.filename?
+        '''
+        try:
+            fname = self.filename(classname, nodeid)
+            return True
+        except IOError:
+            return False
+
+    def destroy(self, classname, nodeid):
+        '''If there is actually FileStorage for this node
+           remove it from the filesystem
+        '''
+        if self.isStoreFile(classname, nodeid):
+            os.remove(self.filename(classname, nodeid))
 
 # vim: set filetype=python ts=4 sw=4 et si
diff --git a/roundup/backends/indexer_common.py b/roundup/backends/indexer_common.py
new file mode 100644 (file)
index 0000000..496475d
--- /dev/null
@@ -0,0 +1,108 @@
+#$Id: indexer_common.py,v 1.11 2008-09-11 19:41:07 schlatterbeck 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.setdefault(propclass.classname,
+                    []).append(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 l in designator_propname.values():
+            for propname in l:
+                propspec[propname] = {}  # used as a set (value doesn't matter)
+
+        # don't unpack hits entries as sqlite3's Row can't be unpacked :(
+        for entry in hits:
+            # skip this result if we don't care about this class/property
+            classname = entry[0]
+            property = entry[2]
+            if ignore.has_key((classname, property)):
+                continue
+
+            # if it's a property on klass, it's easy
+            # (make sure the nodeid is str() not unicode() as returned by some
+            # backends as that can cause problems down the track)
+            nodeid = str(entry[1])
+            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
+            for linkprop in designator_propname[classname]:
+                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
+        propdefs = klass.getprops()
+        for resid in klass.find(**propspec):
+            resid = str(resid)
+            if resid in nodeids:
+                continue # we ignore duplicate resids
+            nodeids[resid] = {}
+            node_dict = nodeids[resid]
+            # now figure out where it came from
+            for linkprop in propspec.keys():
+                v = klass.get(resid, linkprop)
+                # the link might be a Link so deal with a single result or None
+                if isinstance(propdefs[linkprop], hyperdb.Link):
+                    if v is None: continue
+                    v = [v]
+                for nodeid in v:
+                    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
+
index d5545266089f3114cfd13de5b20b8bbfe37d4981..8483637ecea6c11ef3eef676d29647c6e51147a4 100644 (file)
@@ -14,7 +14,7 @@
 #     that promote freedom, but obviously am giving up any rights
 #     to compel such.
 # 
-#$Id: indexer_dbm.py,v 1.1 2004-03-19 04:47:59 richard Exp $
+#$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.
@@ -23,8 +23,9 @@ __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:
+class Indexer(IndexerBase):
     '''Indexes information from roundup's hyperdb to allow efficient
     searching.
 
@@ -36,8 +37,9 @@ class Indexer:
 
     where identifier is (classname, nodeid, propertyname)
     '''
-    def __init__(self, db_path):
-        self.indexdb_path = os.path.join(db_path, 'indexes')
+    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
@@ -95,6 +97,8 @@ class Indexer:
         # 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:
@@ -137,70 +141,6 @@ class Indexer:
         # place
         return re.findall(r'\b\w{2,25}\b', text)
 
-    def search(self, search_terms, klass, ignore={},
-            dre=re.compile(r'([^\d]+)(\d+)')):
-        '''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.find(search_terms)
-        if not hits:
-            return {}
-
-        designator_propname = {}
-        for nm, propclass in klass.getprops().items():
-            if isinstance(propclass, Link) or isinstance(propclass, Multilink):
-                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.values():
-            # 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
-
     # we override this to ignore not 2 < word < 25 and also to fix a bug -
     # the (fail) case.
     def find(self, wordlist):
@@ -233,7 +173,7 @@ class Indexer:
                         del hits[fileid]
         if hits is None:
             return {}
-        return hits
+        return hits.values()
 
     segments = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ#_-!"
     def load_index(self, reload=0, wordlist=None):
@@ -341,9 +281,12 @@ class Indexer:
         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
index 82437b14d1054bc4d753bebc5b8e9f3f70a9a41b..2547300f693d22e3bb7ac825a89937ba37da470a 100644 (file)
@@ -1,14 +1,15 @@
+#$Id: indexer_rdbms.py,v 1.18 2008-09-01 00:43:02 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
+import re, sets
 
-from indexer_dbm import Indexer
+from roundup.backends.indexer_common import Indexer as IndexerBase
 
-class Indexer(Indexer):
-    disallows = {'THE':1, 'THIS':1, 'ZZZ':1, 'THAT':1, 'WITH':1}
+class Indexer(IndexerBase):
     def __init__(self, db):
+        IndexerBase.__init__(self, db)
         self.db = db
         self.reindex = 0
 
@@ -16,7 +17,12 @@ class Indexer(Indexer):
         '''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
@@ -32,6 +38,11 @@ class Indexer(Indexer):
         if mime_type != 'text/plain':
             return
 
+        # Ensure all elements of the identifier are strings 'cos the itemid
+        # column is varchar even if item ids may be numbers elsewhere in the
+        # code. ugh.
+        identifier = tuple(map(str, identifier))
+
         # first, find the id of the (classname, itemid, property)
         a = self.db.arg
         sql = 'select _textid from __textids where _class=%s and '\
@@ -39,68 +50,88 @@ class Indexer(Indexer):
         self.db.cursor.execute(sql, identifier)
         r = self.db.cursor.fetchone()
         if not r:
+            # not previously indexed
             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
-        wordlist = re.findall(r'\b\w{2,25}\b', str(text).upper())
-        words = {}
+        # ok, find all the unique 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 = sets.Set()
         for word in wordlist:
-            if not self.disallows.has_key(word):
-                words[word] = 1
-        words = words.keys()
+            if self.is_stopword(word): continue
+            if len(word) > 25: continue
+            words.add(word)
 
         # 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))
+        sql = 'insert into __words (_word, _textid) values (%s, %s)'%(a, a)
+        words = [(word, id) for word in words]
+        self.db.cursor.executemany(sql, words)
 
     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]
 
-        a = ','.join([self.db.arg] * len(l))
-        sql = 'select distinct(_textid) from __words where _word in (%s)'%a
-        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]))
-        # self.search_index has the results as {some id: identifier} ...
-        # sigh
-        r = {}
-        k = 0
-        for c,n,p in self.db.cursor.fetchall():
-            key = (str(c), str(n), str(p))
-            r[k] = key
-            k += 1
-        return r
+        if not l:
+            return []
 
-    def save_index(self):
-        # the normal RDBMS backend transaction mechanisms will handle this
-        pass
+        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(row[0]) for row in r]))
 
-    def rollback(self):
-        # the normal RDBMS backend transaction mechanisms will handle this
-        pass
+        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()
 
diff --git a/roundup/backends/indexer_xapian.py b/roundup/backends/indexer_xapian.py
new file mode 100644 (file)
index 0000000..ee38fd3
--- /dev/null
@@ -0,0 +1,124 @@
+#$Id: indexer_xapian.py,v 1.6 2007-10-25 07:02:42 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(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(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]
+
index 538529baa268f390e5a77d598519cfcb046264fa..30957c0b14ef0c07aa1e07cb06ae47a2b0a134b4 100644 (file)
@@ -2,7 +2,7 @@
 #                  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 $
+# $Id: portalocker.py,v 1.9 2006-09-09 05:42:45 richard Exp $
 
 """Cross-platform (posix/nt) API for flock-style file locking.
 
@@ -66,7 +66,8 @@ if os.name == 'nt':
     FFFF0000 = -65536
     def lock(file, flags):
         hfile = win32file._get_osfhandle(file.fileno())
-        # LockFileEx is not supported on all Win32 platforms (Win95, Win98, WinME).
+        # 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.
@@ -80,10 +81,12 @@ if os.name == 'nt':
             
             # 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.
+            # 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)
+                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)
@@ -96,7 +99,8 @@ if os.name == 'nt':
                         win32file.LockFile(hfile, 0, 0, FFFF0000, 0)
                         break
                     except win32file.error, e:
-                        # Propagate upwards all exceptions other than lock violation.
+                        # Propagate upwards all exceptions other than lock
+                        # violation.
                         if e[0] != winerror.ERROR_LOCK_VIOLATION:
                             raise e
                     # Sleep and poll again.
@@ -105,7 +109,8 @@ if os.name == 'nt':
                     
     def unlock(file):
         hfile = win32file._get_osfhandle(file.fileno())
-        # UnlockFileEx is not supported on all Win32 platforms (Win95, Win98, WinME).
+        # 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)
@@ -116,7 +121,8 @@ if os.name == 'nt':
                 raise e
             
             # UnlockFileEx is not supported. Use UnlockFile.
-            # Care: the low/high length params are reversed compared to UnLockFileEx.
+            # Care: the low/high length params are reversed compared to
+            # UnLockFileEx.
             win32file.UnlockFile(hfile, 0, 0, FFFF0000, 0)
 
 elif os.name =='posix':
index e468a0f2b4e56e3faae4c4d15465b60eccb0136e..5168367c96d9762da080b75aed9784580b65a4f5 100644 (file)
@@ -1,5 +1,22 @@
-# $Id: rdbms_common.py,v 1.90 2004-04-08 00:40:20 richard Exp $
-''' Relational database (SQL) backend common code.
+#
+# 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.199 2008-08-18 06:25:47 richard Exp $
+""" Relational database (SQL) backend common code.
 
 Basics:
 
@@ -12,7 +29,7 @@ Basics:
 - 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
+- retirement is determined by the __retired__ column being > 0
 
 Database-specific changes may generally be pushed out to the overridable
 sql_* methods, since everything else should be fairly generic. There's
@@ -25,27 +42,42 @@ 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().
-'''
+
+To force a unqiueness constraint on the key properties we put the item
+id into the __retired__ column duing retirement (so it's 0 for "active"
+items) and place a unqiueness constraint on key + __retired__. This is
+particularly important for the users class where multiple users may
+try to have the same username, with potentially many retired users with
+the same name.
+"""
 __docformat__ = 'restructuredtext'
 
 # standard python modules
-import sys, os, time, re, errno, weakref, copy
+import sys, os, time, re, errno, weakref, copy, logging
 
 # roundup modules
-from roundup import hyperdb, date, password, roundupdb, security
+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
+from roundup.support import reversed
+from roundup.i18n import _
 
 # support
 from blobfiles import FileStorage
-from indexer_rdbms import Indexer
+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:
@@ -53,16 +85,36 @@ def _num_cvt(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.
+    """ 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.
-        '''
+        """ Open the database and load the schema from it.
+        """
+        FileStorage.__init__(self, config.UMASK)
         self.config, self.journaltag = config, journaltag
         self.dir = config.DATABASE
         self.classes = {}
@@ -76,6 +128,8 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         # (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
@@ -94,35 +148,35 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         return OneTimeKeys(self)
 
     def open_connection(self):
-        ''' Open a connection to the database, creating it if necessary.
+        """ 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.
-        '''
+        """ Execute the sql with the optional args.
+        """
         if __debug__:
-            print >>hyperdb.DEBUG, (self, sql, args)
+            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.
-        '''
+        """ 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 [].
-        '''
+        """ 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'
-        '''
+        """ Quote the string so it's safe to put in the 'sql quotes'
+        """
         return re.sub("'", "''", str(value))
 
     def init_dbschema(self):
@@ -132,8 +186,8 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         }
 
     def load_dbschema(self):
-        ''' Load the schema definition that the database currently implements
-        '''
+        """ Load the schema definition that the database currently implements
+        """
         self.cursor.execute('select schema from schema')
         schema = self.cursor.fetchone()
         if schema:
@@ -141,21 +195,22 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         else:
             self.database_schema = {}
 
-    def save_dbschema(self, schema):
-        ''' Save the schema definition that the database currently implements
-        '''
+    def save_dbschema(self):
+        """ Save the schema definition that the database currently implements
+        """
         s = repr(self.database_schema)
-        self.sql('insert into schema values (%s)', (s,))
+        self.sql('delete from schema')
+        self.sql('insert into schema values (%s)'%self.arg, (s,))
 
     def post_init(self):
-        ''' Called once the schema initialisation has finished.
+        """ 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 = self.upgrade_db()
+        """
+        save = 0
 
-        # now detect changes in the schema
+        # handle changes in the schema
         tables = self.database_schema['tables']
         for classname, spec in self.classes.items():
             if tables.has_key(classname):
@@ -174,10 +229,13 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
                 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.sql('delete from schema')
-            self.save_dbschema(self.database_schema)
+            self.save_dbschema()
 
         # reindex the db if necessary
         if self.indexer.should_reindex():
@@ -188,45 +246,144 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
 
     # update this number when we need to make changes to the SQL structure
     # of the backen database
-    current_db_version = 2
+    current_db_version = 5
+    db_version_updated = False
     def upgrade_db(self):
-        ''' Update the SQL database to reflect changes in the backend code.
+        """ 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:
+            raise DatabaseError('attempting to run rev %d DATABASE with rev '
+                '%d CODE!'%(version, self.current_db_version))
         if version == self.current_db_version:
             # nothing to do
             return 0
 
-        if version == 1:
+        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_actor_column()
+            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()
+
+        if version < 5:
+            self.fix_version_4_tables()
+
         self.database_schema['version'] = self.current_db_version
+        self.db_version_updated = True
         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 fix_version_4_tables(self):
+        # note this is an explicit call now
+        c = self.cursor
+        for cn, klass in self.classes.items():
+            c.execute('select id from _%s where __retired__<>0'%(cn,))
+            for (id,) in c.fetchall():
+                c.execute('update _%s set __retired__=%s where id=%s'%(cn,
+                    self.arg, self.arg), (id, id))
+
+            if klass.key:
+                self.add_class_key_required_unique_constraint(cn, klass.key)
+
+    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):
-        for klass in self.classes.values():
-            for nodeid in klass.list():
-                klass.index(nodeid)
-        self.indexer.save_index()
 
+    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 : 'VARCHAR(255)',
+        hyperdb.String : 'TEXT',
         hyperdb.Date   : 'TIMESTAMP',
         hyperdb.Link   : 'INTEGER',
         hyperdb.Interval  : 'VARCHAR(255)',
@@ -235,11 +392,11 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         hyperdb.Number    : 'REAL',
     }
     def determine_columns(self, properties):
-        ''' Figure the column names and multilink properties from the spec
+        """ 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]),
@@ -261,15 +418,19 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
             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
+        """ 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()
@@ -278,13 +439,19 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
             # no changes
             return 0
 
-        if __debug__:
-            print >>hyperdb.DEBUG, 'update_class FIRING'
+        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]:
-            keyprop_changes = {'remove': old_spec[0], 'add': new_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 = {}
@@ -308,9 +475,7 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
                 # drop the column
                 sql = 'alter table _%s drop column _%s'%(spec.classname, name)
 
-            if __debug__:
-                print >>hyperdb.DEBUG, 'update_class', (self, sql)
-            self.cursor.execute(sql)
+            self.sql(sql)
         old_has = old_has.has_key
 
         # if we didn't remove the key prop just then, but the key prop has
@@ -327,11 +492,17 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
             if isinstance(prop, Multilink):
                 self.create_multilink_table(spec, propname)
             else:
-                sql = 'alter table _%s add column _%s varchar(255)'%(
-                    spec.classname, propname)
-                if __debug__:
-                    print >>hyperdb.DEBUG, 'update_class', (self, sql)
-                self.cursor.execute(sql)
+                # 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:
@@ -346,47 +517,57 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
 
         return 1
 
-    def create_class_table(self, spec):
-        '''Create the class table for the given Class "spec". Creates the
-        indexes too.'''
+    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)
-        if __debug__:
-            print >>hyperdb.DEBUG, 'create_class', (self, sql)
-        self.cursor.execute(sql)
+        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 the class table for the given spec
+        """
         # create __retired__ index
         index_sql2 = 'create index _%s_retired_idx on _%s(__retired__)'%(
                         spec.classname, spec.classname)
-        if __debug__:
-            print >>hyperdb.DEBUG, 'create_index', (self, index_sql2)
-        self.cursor.execute(index_sql2)
+        self.sql(index_sql2)
 
         # create index for key property
         if spec.key:
-            if __debug__:
-                print >>hyperdb.DEBUG, 'update_class setting keyprop %r'% \
-                    spec.key
             index_sql3 = 'create index _%s_%s_idx on _%s(_%s)'%(
                         spec.classname, spec.key,
                         spec.classname, spec.key)
-            if __debug__:
-                print >>hyperdb.DEBUG, 'create_index', (self, index_sql3)
-            self.cursor.execute(index_sql3)
+            self.sql(index_sql3)
+
+            # and the unique index for key / retired(id)
+            self.add_class_key_required_unique_constraint(spec.classname,
+                spec.key)
+
+        # TODO: create indexes on (selected?) Link property columns, as
+        # they're more likely to be used for lookup
+
+    def add_class_key_required_unique_constraint(self, cn, key):
+        sql = '''create unique index _%s_key_retired_idx
+            on _%s(__retired__, _%s)'''%(cn, cn, key)
+        self.sql(sql)
 
     def drop_class_table_indexes(self, cn, key):
         # drop the old table indexes first
@@ -399,86 +580,74 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
             if not self.sql_index_exists(table_name, index_name):
                 continue
             index_sql = 'drop index '+index_name
-            if __debug__:
-                print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
-            self.cursor.execute(index_sql)
+            self.sql(index_sql)
 
     def create_class_table_key_index(self, cn, key):
-        ''' create the class table for the given spec
-        '''
+        """ create the class table for the given spec
+        """
         sql = 'create index _%s_%s_idx on _%s(_%s)'%(cn, key, cn, key)
-        if __debug__:
-            print >>hyperdb.DEBUG, 'create_class_tab_key_index', (self, sql)
-        self.cursor.execute(sql)
+        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
-        if __debug__:
-            print >>hyperdb.DEBUG, 'drop_class_tab_key_index', (self, sql)
-        self.cursor.execute(sql)
+        if self.sql_index_exists(table_name, index_name):
+            sql = 'drop index '+index_name
+            self.sql(sql)
+
+        # and now the retired unique index too
+        index_name = '_%s_key_retired_idx'%cn
+        if self.sql_index_exists(table_name, index_name):
+            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 
+        """ 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 timestamp, tag varchar(255),
-            action varchar(255), params varchar(25))'''%spec.classname
-        if __debug__:
-            print >>hyperdb.DEBUG, 'create_journal_table', (self, sql)
-        self.cursor.execute(sql)
+        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)
-        if __debug__:
-            print >>hyperdb.DEBUG, 'create_index', (self, sql)
-        self.cursor.execute(sql)
+        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
-        if __debug__:
-            print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
-        self.cursor.execute(index_sql)
+        self.sql(index_sql)
 
     def create_multilink_table(self, spec, ml):
-        ''' Create a multilink table for the "ml" property of the class
+        """ Create a multilink table for the "ml" property of the class
             given by the spec
-        '''
+        """
         # create the table
-        sql = 'create table %s_%s (linkid varchar, nodeid varchar)'%(
+        sql = 'create table %s_%s (linkid INTEGER, nodeid INTEGER)'%(
             spec.classname, ml)
-        if __debug__:
-            print >>hyperdb.DEBUG, 'create_class', (self, sql)
-        self.cursor.execute(sql)
+        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)
-        if __debug__:
-            print >>hyperdb.DEBUG, 'create_index', (self, index_sql)
-        self.cursor.execute(index_sql)
+        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)
-        if __debug__:
-            print >>hyperdb.DEBUG, 'create_index', (self, index_sql)
-        self.cursor.execute(index_sql)
+        self.sql(index_sql)
 
     def drop_multilink_table_indexes(self, classname, ml):
         l = [
@@ -490,13 +659,11 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
             if not self.sql_index_exists(table_name, index_name):
                 continue
             index_sql = 'drop index %s'%index_name
-            if __debug__:
-                print >>hyperdb.DEBUG, 'drop_index', (self, index_sql)
-            self.cursor.execute(index_sql)
+            self.sql(index_sql)
 
     def create_class(self, spec):
-        ''' Create a database table according to the given spec.
-        '''
+        """ Create a database table according to the given spec.
+        """
         cols, mls = self.create_class_table(spec)
         self.create_journal_table(spec)
 
@@ -505,14 +672,14 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
             self.create_multilink_table(spec, ml)
 
     def drop_class(self, cn, spec):
-        ''' Drop the given table from the database.
+        """ 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:
+        for propname, prop in properties:
             if isinstance(prop, Multilink):
                 mls.append(propname)
 
@@ -524,86 +691,71 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         # drop journal table and indexes
         self.drop_journal_table_indexes(cn)
         sql = 'drop table %s__journal'%cn
-        if __debug__:
-            print >>hyperdb.DEBUG, 'drop_class', (self, sql)
-        self.cursor.execute(sql)
+        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)
-            if __debug__:
-                print >>hyperdb.DEBUG, 'drop_class', (self, sql)
-            self.cursor.execute(sql)
+            self.sql(sql)
 
     def drop_class_table(self, cn):
         sql = 'drop table _%s'%cn
-        if __debug__:
-            print >>hyperdb.DEBUG, 'drop_class', (self, sql)
-        self.cursor.execute(sql)
+        self.sql(sql)
 
     #
     # Classes
     #
     def __getattr__(self, classname):
-        ''' A convenient way of calling self.getclass(classname).
-        '''
+        """ A convenient way of calling self.getclass(classname).
+        """
         if self.classes.has_key(classname):
-            if __debug__:
-                print >>hyperdb.DEBUG, '__getattr__', (self, classname)
             return self.classes[classname]
         raise AttributeError, classname
 
     def addclass(self, cl):
-        ''' Add a Class to the hyperdatabase.
-        '''
-        if __debug__:
-            print >>hyperdb.DEBUG, '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.
-        '''
-        if __debug__:
-            print >>hyperdb.DEBUG, '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.
+        """Get the Class object representing a particular class.
 
         If 'classname' is not a valid class name, a KeyError is raised.
-        '''
-        if __debug__:
-            print >>hyperdb.DEBUG, 'getclass', (self, classname)
+        """
         try:
             return self.classes[classname]
         except KeyError:
             raise KeyError, 'There is no class called "%s"'%classname
 
     def clear(self):
-        '''Delete all database contents.
+        """Delete all database contents.
 
         Note: I don't commit here, which is different behaviour to the
               "nuke from orbit" behaviour in the dbs.
-        '''
-        if __debug__:
-            print >>hyperdb.DEBUG, 'clear', (self,)
+        """
+        logging.getLogger('hyperdb').info('clear')
         for cn in self.classes.keys():
             sql = 'delete from _%s'%cn
-            if __debug__:
-                print >>hyperdb.DEBUG, 'clear', (self, sql)
-            self.cursor.execute(sql)
+            self.sql(sql)
 
     #
     # Nodes
@@ -611,18 +763,21 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
 
     hyperdb_to_sql_value = {
         hyperdb.String : str,
-        hyperdb.Date   : lambda x: x.formal(sep=' ', sec='%.3f'),
+        # fractional seconds by default
+        hyperdb.Date   : lambda x: x.formal(sep=' ', sec='%06.3f'),
         hyperdb.Link   : int,
-        hyperdb.Interval  : lambda x: x.serialise(),
+        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.
-        '''
+        """ Add the specified node to its class's db.
+        """
         if __debug__:
-            print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node)
+            logging.getLogger('hyperdb').debug('addnode %s%s %r'%(classname,
+                nodeid, node))
 
         # determine the column definitions and multilink tables
         cl = self.classes[classname]
@@ -657,9 +812,20 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         # 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:
+            if value is not None:
                 value = self.hyperdb_to_sql_value[prop.__class__](value)
             vals.append(value)
         vals.append(nodeid)
@@ -671,9 +837,7 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
 
         # perform the inserts
         sql = 'insert into _%s (%s) values (%s)'%(classname, cols, s)
-        if __debug__:
-            print >>hyperdb.DEBUG, 'addnode', (self, sql, vals)
-        self.cursor.execute(sql, vals)
+        self.sql(sql, vals)
 
         # insert the multilink rows
         for col in mls:
@@ -684,10 +848,11 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
                 self.sql(sql, (entry, nodeid))
 
     def setnode(self, classname, nodeid, values, multilink_changes={}):
-        ''' Change the specified node.
-        '''
+        """ Change the specified node.
+        """
         if __debug__:
-            print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, values)
+            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)
@@ -695,11 +860,6 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
             del self.cache[key]
             self.cache_lru.remove(key)
 
-        # add the special props
-        values = values.copy()
-        values['activity'] = date.Date()
-        values['actor'] = self.getuid()
-
         cl = self.classes[classname]
         props = cl.getprops()
 
@@ -710,6 +870,11 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
             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()
@@ -717,11 +882,25 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         # figure the values to insert
         vals = []
         for col in cols:
-            prop = props[col]
-            value = values[col]
-            if value is not None:
-                value = self.hyperdb_to_sql_value[prop.__class__](value)
-            vals.append(value)
+            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)
 
@@ -733,9 +912,7 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
 
             # perform the update
             sql = 'update _%s set %s where id=%s'%(classname, s, self.arg)
-            if __debug__:
-                print >>hyperdb.DEBUG, 'setnode', (self, sql, vals)
-            self.cursor.execute(sql, vals)
+            self.sql(sql, vals)
 
         # we're probably coming from an import, not a change
         if not multilink_changes:
@@ -780,24 +957,28 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         hyperdb.Link   : str,
         hyperdb.Interval  : date.Interval,
         hyperdb.Password  : lambda x: password.Password(encrypted=x),
-        hyperdb.Boolean   : int,
+        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.
-        '''
-        if __debug__:
-            print >>hyperdb.DEBUG, '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())
@@ -816,6 +997,10 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         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)
@@ -830,7 +1015,9 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
             self.cursor.execute(sql, (nodeid,))
             # extract the first column from the result
             # XXX numeric ids
-            node[col] = [str(x[0]) for x in self.cursor.fetchall()]
+            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)
@@ -840,14 +1027,16 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         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
+        """Remove a node from the database. Called exclusively by the
            destroy() method on Class.
-        '''
-        if __debug__:
-            print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid)
+        """
+        logging.getLogger('hyperdb').info('destroynode %s%s'%(classname, nodeid))
 
         # make sure the node exists
         if not self.hasnode(classname, nodeid):
@@ -878,33 +1067,32 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         sql = 'delete from %s__journal where nodeid=%s'%(classname, self.arg)
         self.sql(sql, (nodeid,))
 
+        # cleanup any blob filestorage when we commit
+        self.transactions.append((FileStorage.destroy, (self, classname, nodeid)))
+
     def hasnode(self, classname, nodeid):
-        ''' Determine if the database has a given node.
-        '''
+        """ Determine if the database has a given node.
+        """
         sql = 'select count(*) from _%s where id=%s'%(classname, self.arg)
-        if __debug__:
-            print >>hyperdb.DEBUG, 'hasnode', (self, sql, nodeid)
-        self.cursor.execute(sql, (nodeid,))
+        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.
-        '''
+        """ Count the number of nodes that exist for a particular Class.
+        """
         sql = 'select count(*) from _%s'%classname
-        if __debug__:
-            print >>hyperdb.DEBUG, 'countnodes', (self, sql)
-        self.cursor.execute(sql)
+        self.sql(sql)
         return self.cursor.fetchone()[0]
 
     def addjournal(self, classname, nodeid, action, params, creator=None,
             creation=None):
-        ''' Journal the Action
+        """ 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:
@@ -920,14 +1108,23 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         cols = 'nodeid,date,tag,action,params'
 
         if __debug__:
-            print >>hyperdb.DEBUG, 'addjournal', (nodeid, journaldate,
-                journaltag, action, params)
+            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.'''
+        """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,))
@@ -935,95 +1132,138 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         # 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__:
-                print >>hyperdb.DEBUG, 'setjournal', (nodeid, journaldate,
-                    journaltag, action, params)
-            self.save_journal(classname, cols, nodeid, journaldate,
+                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
-        '''
+        """ 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())
-        return self.load_journal(classname, cols, nodeid)
+        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
-        '''
-        # make the params db-friendly
-        params = repr(params)
-        dc = self.hyperdb_to_sql_value[hyperdb.Date]
-        entry = (nodeid, dc(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)
-        if __debug__:
-            print >>hyperdb.DEBUG, 'save_journal', (self, sql, entry)
-        self.cursor.execute(sql, entry)
+        self.sql(sql, entry)
 
     def load_journal(self, classname, cols, nodeid):
-        ''' Load the journal from the database
-        '''
+        """ 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)
-        if __debug__:
-            print >>hyperdb.DEBUG, 'load_journal', (self, sql, nodeid)
-        self.cursor.execute(sql, (nodeid,))
-        res = []
-        dc = self.sql_to_hyperdb_value[hyperdb.Date]
-        for nodeid, date_stamp, user, action, params in self.cursor.fetchall():
-            params = eval(params)
-            # XXX numeric ids
-            res.append((str(nodeid), dc(date_stamp), user, action, params))
-        return res
+        self.sql(sql, (nodeid,))
+        return self.cursor.fetchall()
 
     def pack(self, pack_before):
-        ''' Delete all journal entries except "create" before 'pack_before'.
-        '''
-        # get a 'yyyymmddhhmmss' version of the date
-        date_stamp = pack_before.serialise()
+        """ 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)
-            if __debug__:
-                print >>hyperdb.DEBUG, 'pack', (self, sql, date_stamp)
-            self.cursor.execute(sql, (date_stamp,))
+            self.sql(sql, (date_stamp,))
+
+    def sql_commit(self, fail_ok=False):
+        """ Actually commit to the database.
+        """
+        logging.getLogger('hyperdb').info('commit')
 
-    def sql_commit(self):
-        ''' Actually commit to the database.
-        '''
-        if __debug__:
-            print >>hyperdb.DEBUG, '+++ commit database connection +++'
         self.conn.commit()
 
-    def commit(self):
-        ''' Commit the current transactions.
+        # open a new cursor for subsequent work
+        self.cursor = self.conn.cursor()
+
+    def commit(self, fail_ok=False):
+        """ Commit the current transactions.
 
         Save all data changed since the database was opened or since the
         last commit() or rollback().
-        '''
-        if __debug__:
-            print >>hyperdb.DEBUG, 'commit', (self,)
 
+        fail_ok indicates that the commit is allowed to fail. This is used
+        in the web interface when committing cleaning of the session
+        database. We don't care if there's a concurrency issue there.
+
+        The only backend this seems to affect is postgres.
+        """
         # commit the database
-        self.sql_commit()
+        self.sql_commit(fail_ok)
 
         # now, do all the other transaction stuff
         for method, args in self.transactions:
             method(*args)
 
-        # save the indexer state
+        # save the indexer
         self.indexer.save_index()
 
         # clear out the transactions
@@ -1033,13 +1273,12 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         self.conn.rollback()
 
     def rollback(self):
-        ''' Reverse all actions from the current transaction.
+        """ 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.
-        '''
-        if __debug__:
-            print >>hyperdb.DEBUG, 'rollback', (self,)
+        """
+        logging.getLogger('hyperdb').info('rollback')
 
         self.sql_rollback()
 
@@ -1054,13 +1293,12 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
         self.clearCache()
 
     def sql_close(self):
-        if __debug__:
-            print >>hyperdb.DEBUG, '+++ close database connection +++'
+        logging.getLogger('hyperdb').info('close')
         self.conn.close()
 
     def close(self):
-        ''' Close off the connection.
-        '''
+        """ Close off the connection.
+        """
         self.indexer.close()
         self.sql_close()
 
@@ -1068,85 +1306,59 @@ class Database(FileStorage, hyperdb.Database, roundupdb.Database):
 # The base Class class
 #
 class Class(hyperdb.Class):
-    ''' The handle to a particular class of nodes in a hyperdatabase.
-        
+    """ 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)
-
-        self.auditors = {'create': [], 'set': [], 'retire': [], 'restore': []}
-        self.reactors = {'create': [], 'set': [], 'retire': [], 'restore': []}
+    """
 
     def schema(self):
-        ''' A dumpable version of the schema that we can store in the
+        """ 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
-        '''
+        """Turn journalling on for this class
+        """
         self.do_journal = 1
 
     def disableJournalling(self):
-        '''Turn journalling off for this class
-        '''
+        """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.
+        """ 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.
-        '''
+        """ 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'
+            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'):
@@ -1197,8 +1409,10 @@ class Class(hyperdb.Class):
                         (self.classname, newid, key))
 
             elif isinstance(prop, Multilink):
-                if type(value) != type([]):
-                    raise TypeError, 'new property "%s" not a list of ids'%key
+                if value is None:
+                    value = []
+                if not hasattr(value, '__iter__'):
+                    raise TypeError, 'new property "%s" not an iterable of ids'%key
 
                 # clean up and validate the list of links
                 link_class = self.properties[key].classname
@@ -1231,7 +1445,9 @@ class Class(hyperdb.Class):
             elif isinstance(prop, String):
                 if type(value) != type('') and type(value) != type(u''):
                     raise TypeError, 'new property "%s" not a string'%key
-                self.db.indexer.add_text((self.classname, newid, key), value)
+                if prop.indexme:
+                    self.db.indexer.add_text((self.classname, newid, key),
+                        value)
 
             elif isinstance(prop, Password):
                 if not isinstance(value, password.Password):
@@ -1271,21 +1487,20 @@ class Class(hyperdb.Class):
         # done
         self.db.addnode(self.classname, newid, propvalues)
         if self.do_journal:
-            self.db.addjournal(self.classname, newid, 'create', {})
+            self.db.addjournal(self.classname, newid, ''"create", {})
 
         # XXX numeric ids
         return str(newid)
 
-    _marker = []
     def get(self, nodeid, propname, default=_marker, cache=1):
-        '''Get the value of a property on an existing node of this class.
+        """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
 
@@ -1316,8 +1531,10 @@ class Class(hyperdb.Class):
         # get the property (raises KeyErorr if invalid)
         prop = self.properties[propname]
 
-        if not d.has_key(propname):
-            if default is self._marker:
+        # 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:
@@ -1332,8 +1549,8 @@ class Class(hyperdb.Class):
         return d[propname]
 
     def set(self, nodeid, **propvalues):
-        '''Modify a property on an existing node of this class.
-        
+        """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.
 
@@ -1348,16 +1565,16 @@ class Class(hyperdb.Class):
 
         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        
+        return propvalues
 
     def set_inner(self, nodeid, **propvalues):
-        ''' Called by set, in-between the audit and react calls.
-        ''' 
+        """ Called by set, in-between the audit and react calls.
+        """
         if not propvalues:
             return propvalues
 
@@ -1370,13 +1587,16 @@ class Class(hyperdb.Class):
             raise KeyError, '"id" is reserved'
 
         if self.db.journaltag is None:
-            raise DatabaseError, 'Database open read-only'
+            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 = {}
 
@@ -1432,17 +1652,19 @@ class Class(hyperdb.Class):
                 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))
+                        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.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'\
+                if value is None:
+                    value = []
+                if not hasattr(value, '__iter__'):
+                    raise TypeError, 'new property "%s" not an iterable of'\
                         ' ids'%propname
                 link_class = self.properties[propname].classname
                 l = []
@@ -1507,8 +1729,10 @@ class Class(hyperdb.Class):
             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
-                self.db.indexer.add_text((self.classname, nodeid, propname),
-                    value)
+                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):
@@ -1542,25 +1766,34 @@ class Class(hyperdb.Class):
         if not propvalues:
             return propvalues
 
-        # do the set, and journal it
+        # 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)
+            self.db.addjournal(self.classname, nodeid, ''"set", journalvalues)
 
-        return propvalues        
+        return propvalues
 
     def retire(self, nodeid):
-        '''Retire a node.
-        
+        """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'
+            raise DatabaseError, _('Database open read-only')
 
         self.fireAuditors('retire', nodeid, None)
 
@@ -1568,21 +1801,19 @@ class Class(hyperdb.Class):
         # conversion (hello, sqlite)
         sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
             self.db.arg, self.db.arg)
-        if __debug__:
-            print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid)
-        self.db.cursor.execute(sql, (1, nodeid))
+        self.db.sql(sql, (nodeid, nodeid))
         if self.do_journal:
-            self.db.addjournal(self.classname, nodeid, 'retired', None)
+            self.db.addjournal(self.classname, nodeid, ''"retired", None)
 
         self.fireReactors('retire', nodeid, None)
 
     def restore(self, nodeid):
-        '''Restore a retired node.
+        """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'
+            raise DatabaseError, _('Database open read-only')
 
         node = self.db.getnode(self.classname, nodeid)
         # check if key property was overrided
@@ -1600,27 +1831,23 @@ class Class(hyperdb.Class):
         # conversion (hello, sqlite)
         sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
             self.db.arg, self.db.arg)
-        if __debug__:
-            print >>hyperdb.DEBUG, 'restore', (self, sql, nodeid)
-        self.db.cursor.execute(sql, (0, nodeid))
+        self.db.sql(sql, (0, nodeid))
         if self.do_journal:
-            self.db.addjournal(self.classname, nodeid, 'restored', None)
+            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
-        '''
+        """Return true if the node is rerired
+        """
         sql = 'select __retired__ from _%s where id=%s'%(self.classname,
             self.db.arg)
-        if __debug__:
-            print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid)
-        self.db.cursor.execute(sql, (nodeid,))
-        return int(self.db.sql_fetchone()[0])
+        self.db.sql(sql, (nodeid,))
+        return int(self.db.sql_fetchone()[0]) > 0
 
     def destroy(self, nodeid):
-        '''Destroy a node.
-        
+        """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
@@ -1637,13 +1864,13 @@ class Class(hyperdb.Class):
         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'
+            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.
+        """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.
@@ -1654,76 +1881,49 @@ class Class(hyperdb.Class):
 
         '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
-        '''
+        """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.
+        """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.
-        '''
-        # XXX create an index on the key prop column. We should also 
-        # record that we've created this index in the schema somewhere.
+        """
         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 the name of the key property for this class or None."""
         return self.key
 
-    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:
-
-        1. key property
-        2. "name" property
-        3. "title" property
-        4. first property from the sorted property name list
-        '''
-        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 lookup(self, keyvalue):
-        '''Locate a particular node by its key property and return its id.
+        """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"%(
+        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))
+        self.db.sql(sql, (str(keyvalue), 0))
 
         # see if there was a result that's not retired
         row = self.db.sql_fetchone()
@@ -1736,7 +1936,7 @@ class Class(hyperdb.Class):
         return str(row[0])
 
     def find(self, **propspec):
-        '''Get the ids of nodes in this class which link to the given nodes.
+        """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, }
@@ -1744,16 +1944,12 @@ class Class(hyperdb.Class):
                    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:
+        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})
-        '''
-        if __debug__:
-            print >>hyperdb.DEBUG, 'find', (self, propspec)
-
+        """
         # shortcut
         if not propspec:
             return []
@@ -1769,8 +1965,8 @@ class Class(hyperdb.Class):
 
         # first, links
         a = self.db.arg
-        allvalues = (1,)
-        o = []
+        allvalues = ()
+        sql = []
         where = []
         for prop, values in propspec:
             if not isinstance(props[prop], hyperdb.Link):
@@ -1783,11 +1979,18 @@ class Class(hyperdb.Class):
             elif values is None:
                 where.append('_%s is NULL'%prop)
             else:
-                allvalues += tuple(values.keys())
-                where.append('_%s in (%s)'%(prop, ','.join([a]*len(values))))
-        tables = ['_%s'%self.classname]
+                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:
-            o.append('(' + ' and '.join(where) + ')')
+            allvalues = (0, ) + 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:
@@ -1795,6 +1998,7 @@ class Class(hyperdb.Class):
                 continue
             if not values:
                 continue
+            allvalues += (0, )
             if type(values) is type(''):
                 allvalues += (values,)
                 s = a
@@ -1802,33 +2006,26 @@ class Class(hyperdb.Class):
                 allvalues += tuple(values.keys())
                 s = ','.join([a]*len(values))
             tn = '%s_%s'%(self.classname, prop)
-            tables.append(tn)
-            o.append('(id=%s.nodeid and %s.linkid in (%s))'%(tn, tn, s))
+            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 o:
+        if not sql:
             return []
-        elif len(o) > 1:
-            o = '(' + ' or '.join(['(%s)'%i for i in o]) + ')'
-        else:
-            o = o[0]
-        t = ', '.join(tables)
-        sql = 'select distinct(id) from %s where __retired__ <> %s and %s'%(
-            t, a, o)
+        sql = ' union '.join(sql)
         self.db.sql(sql, allvalues)
         # XXX numeric ids
         l = [str(x[0]) for x in self.db.sql_fetchall()]
-        if __debug__:
-            print >>hyperdb.DEBUG, 'find ... ', l
         return l
 
     def stringFind(self, **requirements):
-        '''Locate a particular node by matching a set of its String
+        """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():
@@ -1840,276 +2037,379 @@ class Class(hyperdb.Class):
 
         # 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)
+        sql = 'select id from _%s where %s and __retired__=%s'%(
+            self.classname, s, self.db.arg)
         args.append(0)
         self.db.sql(sql, tuple(args))
         # XXX numeric ids
         l = [str(x[0]) for x in self.db.sql_fetchall()]
-        if __debug__:
-            print >>hyperdb.DEBUG, 'find ... ', l
         return l
 
     def list(self):
-        ''' Return a list of the ids of the active nodes in this class.
-        '''
+        """ 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.
+        """ Retrieve all the ids of the nodes for a particular Class.
 
-            Set retired=None to get all nodes. Otherwise it'll get all the 
+            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:
+            args = (0, )
             if retired:
-                args = (0, )
+                compare = '>'
             else:
-                args = (1, )
-            sql = 'select id from _%s where __retired__ <> %s'%(self.classname,
-                self.db.arg)
+                compare = '='
+            sql = 'select id from _%s where __retired__%s%s'%(self.classname,
+                compare, self.db.arg)
         else:
             args = ()
             sql = 'select id from _%s'%self.classname
-        if __debug__:
-            print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired)
-        self.db.cursor.execute(sql, args)
+        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
+    def _subselect(self, classname, multilink_table):
+        """Create a subselect. This is factored out because some
+           databases (hmm only one, so far) doesn't support subselects
+           look for "I can't believe it's not a toy RDBMS" in the mysql
+           backend.
+        """
+        return '_%s.id not in (select nodeid from %s)'%(classname,
+            multilink_table)
+
+    # Some DBs order NULL values last. Set this variable in the backend
+    # for prepending an order by clause for each attribute that causes
+    # correct sort order for NULLs. Examples:
+    # order_by_null_values = '(%s is not NULL)'
+    # order_by_null_values = 'notnull(%s)'
+    # The format parameter is replaced with the attribute.
+    order_by_null_values = None
+
+    def filter(self, search_matches, filterspec, sort=[], group=[]):
+        """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
+        "sort" and "group" are [(dir, prop), ...] where dir is '+', '-'
+        or None and prop is a prop name or None. Note that for
+        backward-compatibility reasons a single (dir, prop) tuple is
+        also allowed.
 
-        "search_matches" is {nodeid: marker}
+        "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.
-        '''
-        # just don't bother if the full-text search matched diddly
+        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 []
 
-        cn = self.classname
+        if __debug__:
+            start_t = time.time()
 
-        timezone = self.db.getUserTimezone()
-        
-        # figure the WHERE clause from the filterspec
-        props = self.getprops()
-        frum = ['_'+cn]
-        where = []
-        args = []
+        icn = 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
-        for k, v in filterspec.items():
-            propclass = props[k]
-            # now do other where clause stuff
-            if isinstance(propclass, Multilink):
-                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('id not in (select nodeid from %s)'%tn)
-                elif isinstance(v, type([])):
-                    frum.append(tn)
-                    s = ','.join([a for x in v])
-                    where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s))
-                    args = args + v
-                else:
-                    frum.append(tn)
-                    where.append('id=%s.nodeid and %s.linkid=%s'%(tn, tn, a))
-                    args.append(v)
-            elif k == 'id':
-                if isinstance(v, type([])):
-                    s = ','.join([a for x in v])
-                    where.append('%s in (%s)'%(k, s))
-                    args = args + v
-                else:
-                    where.append('%s=%s'%(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]
+        # figure the WHERE clause from the filterspec
+        mlfilt = 0      # are we joining with Multilink tables?
+        sortattr = self._sortattr (group = group, sort = sort)
+        proptree = self._proptree(filterspec, sortattr)
+        mlseen = 0
+        for pt in reversed(proptree.sortattr):
+            p = pt
+            while p.parent:
+                if isinstance (p.propclass, Multilink):
+                    mlseen = True
+                if mlseen:
+                    p.sort_ids_needed = True
+                    p.tree_sort_done = False
+                p = p.parent
+            if not mlseen:
+                pt.attr_sort_done = pt.tree_sort_done = True
+        proptree.compute_sort_done()
 
-                # now add to the where clause
-                where.append(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v]))
-                # note: args are embedded in the query string now
-            elif isinstance(propclass, Link):
-                if isinstance(v, type([])):
-                    if '-1' in v:
-                        v = v[:]
-                        v.remove('-1')
-                        xtra = ' or _%s is NULL'%k
+        ordercols = []
+        auxcols = {}
+        mlsort = []
+        rhsnum = 0
+        for p in proptree:
+            oc = None
+            cn = p.classname
+            ln = p.uniqname
+            pln = p.parent.uniqname
+            pcn = p.parent.classname
+            k = p.name
+            v = p.val
+            propclass = p.propclass
+            if p.sort_type > 0:
+                oc = ac = '_%s._%s'%(pln, k)
+            if isinstance(propclass, Multilink):
+                if p.sort_type < 2:
+                    mlfilt = 1
+                    tn = '%s_%s'%(pcn, k)
+                    if v in ('-1', ['-1']):
+                        # only match rows that have count(linkid)=0 in the
+                        # corresponding multilink table)
+                        where.append(self._subselect(pcn, tn))
                     else:
-                        xtra = ''
-                    if v:
+                        frum.append(tn)
+                        where.append('_%s.id=%s.nodeid'%(pln,tn))
+                        if p.children:
+                            frum.append('_%s as _%s' % (cn, ln))
+                            where.append('%s.linkid=_%s.id'%(tn, ln))
+                        if p.has_values:
+                            if isinstance(v, type([])):
+                                s = ','.join([a for x in v])
+                                where.append('%s.linkid in (%s)'%(tn, s))
+                                args = args + v
+                            else:
+                                where.append('%s.linkid=%s'%(tn, a))
+                                args.append(v)
+                if p.sort_type > 0:
+                    assert not p.attr_sort_done and not p.sort_ids_needed
+            elif k == 'id':
+                if p.sort_type < 2:
+                    if isinstance(v, type([])):
                         s = ','.join([a for x in v])
-                        where.append('(_%s in (%s)%s)'%(k, s, xtra))
+                        where.append('_%s.%s in (%s)'%(pln, k, s))
                         args = args + v
                     else:
-                        where.append('_%s is NULL'%k)
-                else:
-                    if v == '-1':
-                        v = None
-                        where.append('_%s is NULL'%k)
-                    else:
-                        where.append('_%s=%s'%(k, a))
+                        where.append('_%s.%s=%s'%(pln, k, a))
                         args.append(v)
-            elif isinstance(propclass, Date):
+                if p.sort_type > 0:
+                    oc = ac = '_%s.id'%pln
+            elif isinstance(propclass, String):
+                if p.sort_type < 2:
+                    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'"%(pln, k, s) for s in v])
+                        +')')
+                    # note: args are embedded in the query string now
+                if p.sort_type > 0:
+                    oc = ac = 'lower(_%s._%s)'%(pln, k)
+            elif isinstance(propclass, Link):
+                if p.sort_type < 2:
+                    if p.children:
+                        if p.sort_type == 0:
+                            frum.append('_%s as _%s' % (cn, ln))
+                        where.append('_%s._%s=_%s.id'%(pln, k, ln))
+                    if p.has_values:
+                        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:
+                                if d.has_key(None): del d[None]
+                                l.append('_%s._%s is NULL'%(pln, k))
+                            if d:
+                                v = d.keys()
+                                s = ','.join([a for x in v])
+                                l.append('(_%s._%s in (%s))'%(pln, 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'%(pln, k))
+                            else:
+                                where.append('_%s._%s=%s'%(pln, k, a))
+                                args.append(v)
+                if p.sort_type > 0:
+                    lp = p.cls.labelprop()
+                    oc = ac = '_%s._%s'%(pln, k)
+                    if lp != 'id':
+                        if p.tree_sort_done and p.sort_type > 0:
+                            loj.append(
+                                'LEFT OUTER JOIN _%s as _%s on _%s._%s=_%s.id'%(
+                                cn, ln, pln, k, ln))
+                        oc = '_%s._%s'%(ln, lp)
+            elif isinstance(propclass, Date) and p.sort_type < 2:
                 dc = self.db.hyperdb_to_sql_value[hyperdb.Date]
                 if isinstance(v, type([])):
                     s = ','.join([a for x in v])
-                    where.append('_%s in (%s)'%(k, s))
-                    args = args + [dc(date.Date(v)) for x in v]
+                    where.append('_%s._%s in (%s)'%(pln, k, s))
+                    args = args + [dc(date.Date(x)) for x in v]
                 else:
                     try:
                         # Try to filter on range of dates
-                        date_rng = Range(v, date.Date, offset=timezone)
+                        date_rng = propclass.range_from_raw(v, self.db)
                         if date_rng.from_value:
-                            where.append('_%s >= %s'%(k, a))                            
+                            where.append('_%s._%s >= %s'%(pln, k, a))
                             args.append(dc(date_rng.from_value))
                         if date_rng.to_value:
-                            where.append('_%s <= %s'%(k, a))
+                            where.append('_%s._%s <= %s'%(pln, k, a))
                             args.append(dc(date_rng.to_value))
                     except ValueError:
                         # If range creation fails - ignore that search parameter
-                        pass                        
+                        pass
             elif isinstance(propclass, Interval):
+                # filter/sort using the __<prop>_int__ column
+                if p.sort_type < 2:
+                    if isinstance(v, type([])):
+                        s = ','.join([a for x in v])
+                        where.append('_%s.__%s_int__ in (%s)'%(pln, 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'%(pln, k, a))
+                                args.append(date_rng.from_value.as_seconds())
+                            if date_rng.to_value:
+                                where.append('_%s.__%s_int__ <= %s'%(pln, k, a))
+                                args.append(date_rng.to_value.as_seconds())
+                        except ValueError:
+                            # If range creation fails - ignore search parameter
+                            pass
+                if p.sort_type > 0:
+                    oc = ac = '_%s.__%s_int__'%(pln,k)
+            elif p.sort_type < 2:
                 if isinstance(v, type([])):
                     s = ','.join([a for x in v])
-                    where.append('_%s in (%s)'%(k, s))
-                    args = args + [date.Interval(x).serialise() 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'%(k, a))
-                            args.append(date_rng.from_value.serialise())
-                        if date_rng.to_value:
-                            where.append('_%s <= %s'%(k, a))
-                            args.append(date_rng.to_value.serialise())
-                    except ValueError:
-                        # If range creation fails - ignore that search parameter
-                        pass                        
-                    #where.append('_%s=%s'%(k, a))
-                    #args.append(date.Interval(v).serialise())
-            else:
-                if isinstance(v, type([])):
-                    s = ','.join([a for x in v])
-                    where.append('_%s in (%s)'%(k, s))
+                    where.append('_%s._%s in (%s)'%(pln, k, s))
                     args = args + v
                 else:
-                    where.append('_%s=%s'%(k, a))
+                    where.append('_%s._%s=%s'%(pln, k, a))
                     args.append(v)
+            if oc:
+                if p.sort_ids_needed:
+                    auxcols[ac] = p
+                if p.tree_sort_done and p.sort_direction:
+                    # Don't select top-level id twice
+                    if p.name != 'id' or p.parent != proptree:
+                        ordercols.append(oc)
+                    desc = ['', ' desc'][p.sort_direction == '-']
+                    # Some SQL dbs sort NULL values last -- we want them first.
+                    if (self.order_by_null_values and p.name != 'id'):
+                        nv = self.order_by_null_values % oc
+                        ordercols.append(nv)
+                        p.orderby.append(nv + desc)
+                    p.orderby.append(oc + desc)
+
+        props = self.getprops()
 
         # don't match retired nodes
-        where.append('__retired__ <> 1')
+        where.append('_%s.__retired__=0'%icn)
 
         # 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('id in (%s)'%s)
+            where.append('_%s.id in (%s)'%(icn, s))
             args = args + v
 
-        # "grouping" is just the first-order sorting in the SQL fetch
-        orderby = []
-        ordercols = []
-        mlsort = []
-        for sortby in group, sort:
-            sdir, prop = sortby
-            if sdir and prop:
-                if isinstance(props[prop], Multilink):
-                    mlsort.append(sortby)
-                    continue
-                elif prop == 'id':
-                    o = 'id'
-                else:
-                    o = '_'+prop
-                    ordercols.append(o)
-                if sdir == '-':
-                    o += ' desc'
-                orderby.append(o)
-
         # construct the SQL
+        frum.append('_'+icn)
         frum = ','.join(frum)
         if where:
             where = ' where ' + (' and '.join(where))
         else:
             where = ''
-        cols = ['distinct(id)']
-        if orderby:
+        if mlfilt:
+            # we're joining tables on the id, so we will get dupes if we
+            # don't distinct()
+            cols = ['distinct(_%s.id)'%icn]
+        else:
+            cols = ['_%s.id'%icn]
+        if ordercols:
             cols = cols + ordercols
-            order = ' order by %s'%(','.join(orderby))
+        order = []
+        # keep correct sequence of order attributes.
+        for sa in proptree.sortattr:
+            if not sa.attr_sort_done:
+                continue
+            order.extend(sa.orderby)
+        if order:
+            order = ' order by %s'%(','.join(order))
         else:
             order = ''
+        for o, p in auxcols.iteritems ():
+            cols.append (o)
+            p.auxcol = len (cols) - 1
+
         cols = ','.join(cols)
-        sql = 'select %s from %s %s%s'%(cols, frum, where, order)
+        loj = ' '.join(loj)
+        sql = 'select %s from %s %s %s%s'%(cols, frum, loj, where, order)
         args = tuple(args)
-        if __debug__:
-            print >>hyperdb.DEBUG, 'filter', (self, sql, args)
-        if args:
-            self.db.cursor.execute(sql, args)
-        else:
-            # psycopg doesn't like empty args
-            self.db.cursor.execute(sql)
+        __traceback_info__ = (sql, args)
+        self.db.sql(sql, args)
         l = self.db.sql_fetchall()
 
+        # Compute values needed for sorting in proptree.sort
+        for p in auxcols.itervalues():
+            p.sort_ids = p.sort_result = [row[p.auxcol] for row in l]
         # return the IDs (the first column)
         # XXX numeric ids
-        l =  [str(row[0]) for row in l]
+        l = [str(row[0]) for row in l]
+        l = proptree.sort (l)
 
-        if not mlsort:
-            return l
+        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]):
-                if dir == '-':
-                    return cmp(b[1][i], a[1][i])
-                else:
-                    return cmp(a[1][i], b[1][i])
-            r.sort(sortfun)
-            i += 1
-        return [i[0] for i in r]
+    def filter_sql(self, sql):
+        """Return a list of the ids of the items in this class that match
+        the SQL provided. The SQL is a complete "select" statement.
+
+        The SQL select must include the item id as the first column.
+
+        This function DOES NOT filter out retired items, add on a where
+        clause "__retired__=0" if you don't want retired nodes.
+        """
+        if __debug__:
+            start_t = time.time()
+
+        self.db.sql(sql)
+        l = self.db.sql_fetchall()
+
+        if __debug__:
+            self.db.stats['filtering'] += (time.time() - start_t)
+        return l
 
     def count(self):
-        '''Get the number of nodes in this class.
+        """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.
+        """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()
@@ -2120,64 +2420,34 @@ class Class(hyperdb.Class):
         return d
 
     def addprop(self, **properties):
-        '''Add properties to this class.
+        """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
-        '''
+        """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)))
 
-
-    #
-    # Detector interface
-    #
-    def audit(self, event, detector):
-        '''Register a detector
-        '''
-        l = self.auditors[event]
-        if detector not in l:
-            self.auditors[event].append(detector)
-
-    def fireAuditors(self, action, nodeid, newvalues):
-        '''Fire all registered auditors.
-        '''
-        for audit in self.auditors[action]:
-            audit(self.db, self, nodeid, newvalues)
-
-    def react(self, event, detector):
-        '''Register a detector
-        '''
-        l = self.reactors[event]
-        if detector not in l:
-            self.reactors[event].append(detector)
-
-    def fireReactors(self, action, nodeid, oldvalues):
-        '''Fire all registered reactors.
-        '''
-        for react in self.reactors[action]:
-            react(self.db, self, nodeid, oldvalues)
-
     #
     # import / export support
     #
     def export_list(self, propnames, nodeid):
-        ''' Export a node - generate a list of CSV-able data in the order
+        """ 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:
@@ -2197,21 +2467,24 @@ class Class(hyperdb.Class):
         return l
 
     def import_list(self, propnames, proplist):
-        ''' Import a node - all information including "id" is present and
+        """ 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'
+            raise DatabaseError, _('Database open read-only')
         properties = self.getprops()
 
         # make the new node's property map
         d = {}
         retire = 0
-        newid = None
+        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])
@@ -2221,7 +2494,6 @@ class Class(hyperdb.Class):
 
             # "unmarshal" where necessary
             if propname == 'id':
-                newid = value
                 continue
             elif propname == 'is retired':
                 # is the item retired?
@@ -2244,15 +2516,27 @@ class Class(hyperdb.Class):
                 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 or not self.hasnode(newid):
+        if newid is None:
             newid = self.db.newid(self.classname)
-            self.db.addnode(self.classname, newid, d)
+
+        # insert new node or update existing?
+        if not self.hasnode(newid):
+            self.db.addnode(self.classname, newid, d) # insert
         else:
-            # update
-            self.db.setnode(self.classname, newid, d)
+            self.db.setnode(self.classname, newid, d) # update
 
         # retire?
         if retire:
@@ -2260,26 +2544,29 @@ class Class(hyperdb.Class):
             # conversion (hello, sqlite)
             sql = 'update _%s set __retired__=%s where id=%s'%(self.classname,
                 self.db.arg, self.db.arg)
-            if __debug__:
-                print >>hyperdb.DEBUG, 'retire', (self, sql, newid)
-            self.db.cursor.execute(sql, (1, newid))
+            self.db.sql(sql, (newid, newid))
         return newid
 
     def export_journals(self):
-        '''Export a class's journal - generate a list of lists of
+        """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:
@@ -2290,15 +2577,19 @@ class Class(hyperdb.Class):
                             value = value.get_tuple()
                         elif isinstance(prop, Password):
                             value = str(value)
-                        params[propname] = value
+                        export_data[propname] = value
+                    params = export_data
+                elif action == 'create' and params:
+                    # old tracker with data stored in the create!
+                    params = {}
                 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.'''
+        """Import a class's journal.
+
+        Uses setjournal() to set the journal for each item."""
         properties = self.getprops()
         d = {}
         for l in entries:
@@ -2319,25 +2610,36 @@ class Class(hyperdb.Class):
                         pwd.unpack(value)
                         value = pwd
                     params[propname] = value
+            elif action == 'create' and params:
+                # old tracker with data stored in the create!
+                params = {}
             r.append((nodeid, date.Date(jdate), user, action, params))
 
         for nodeid, l in d.items():
             self.db.setjournal(self.classname, nodeid, l)
 
-class FileClass(Class, hyperdb.FileClass):
-    '''This class defines a large chunk of data. To support this, it has a
+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.
-    '''
-    default_mime_type = 'text/plain'
+    """
+    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
-        '''
+        """ 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)
@@ -2353,41 +2655,23 @@ class FileClass(Class, hyperdb.FileClass):
         mime_type = propvalues.get('type', self.default_mime_type)
 
         # and index!
-        self.db.indexer.add_text((self.classname, newid, 'content'), content,
-            mime_type)
-
-        # fire reactors
-        self.fireReactors('create', newid, None)
+        if self.properties['content'].indexme:
+            self.db.indexer.add_text((self.classname, newid, 'content'),
+                content, mime_type)
 
         # store off the content as a file
         self.db.storefile(self.classname, newid, None, content)
-        return newid
 
-    def import_list(self, propnames, proplist):
-        ''' Trap the "content" property...
-        '''
-        # dupe this list so we don't affect others
-        propnames = propnames[:]
-
-        # extract the "content" property from the proplist
-        i = propnames.index('content')
-        content = eval(proplist[i])
-        del propnames[i]
-        del proplist[i]
-
-        # do the normal import
-        newid = Class.import_list(self, propnames, proplist)
+        # fire reactors
+        self.fireReactors('create', newid, None)
 
-        # save off the "content" file
-        self.db.storefile(self.classname, newid, None, content)
         return newid
 
-    _marker = []
     def get(self, nodeid, propname, default=_marker, cache=1):
-        ''' Trap the content propname and get it from the file
+        """ 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:
@@ -2396,24 +2680,14 @@ class FileClass(Class, hyperdb.FileClass):
                 # 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 self._marker:
+        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.
-        '''
-        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
-        '''
+        """ Snarf the "content" propvalue and update it in a file
+        """
         self.fireAuditors('set', itemid, propvalues)
         oldvalues = copy.deepcopy(self.db.getnode(self.classname, itemid))
 
@@ -2428,28 +2702,48 @@ class FileClass(Class, hyperdb.FileClass):
 
         # do content?
         if content:
-            # store and index
+            # store and possibly index
             self.db.storefile(self.classname, itemid, None, content)
-            mime_type = propvalues.get('type', self.get(itemid, 'type'))
-            if not mime_type:
-                mime_type = self.default_mime_type
-            self.db.indexer.add_text((self.classname, itemid, 'content'),
-                content, mime_type)
+            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",
+        """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'):
@@ -2464,3 +2758,4 @@ class IssueClass(Class, roundupdb.IssueClass):
             properties['superseder'] = hyperdb.Multilink(classname)
         Class.__init__(self, db, classname, **properties)
 
+# vim: set et sts=4 sw=4 :
index 2405a5c2d6306cdd65c035cc276e1ef5c5498a3f..1087967b6096a6d8d0e8e311238c19c378a488e0 100644 (file)
@@ -1,4 +1,4 @@
-#$Id: sessions_dbm.py,v 1.5 2004-03-31 23:08:38 richard Exp $
+#$Id: sessions_dbm.py,v 1.10 2008-08-18 05:04:01 richard Exp $
 """This module defines a very basic store that's used by the CGI interface
 to store session and one-time-key information.
 
@@ -8,6 +8,8 @@ class. It's now also used for One Time Key handling too.
 __docformat__ = 'restructuredtext'
 
 import anydbm, whichdb, os, marshal, time
+from roundup import hyperdb
+from roundup.i18n import _
 
 class BasicDatabase:
     ''' Provide a nice encapsulation of an anydbm store.
@@ -19,8 +21,7 @@ class BasicDatabase:
     def __init__(self, db):
         self.config = db.config
         self.dir = db.config.DATABASE
-        # ensure files are group readable and writable
-        os.umask(0002)
+        os.umask(db.config.UMASK)
 
     def exists(self, infoid):
         db = self.opendb('c')
@@ -45,7 +46,8 @@ class BasicDatabase:
         if os.path.exists(path):
             db_type = whichdb.whichdb(path)
             if not db_type:
-                raise hyperdb.DatabaseError, "Couldn't identify database 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!
@@ -131,17 +133,21 @@ class BasicDatabase:
         pass
 
     def updateTimestamp(self, sessid):
-        self.set(sessid, __timestamp=time.time())
-
-    def clean(self, now):
-        """Age sessions, remove when they haven't been used for a week.
-        """
+        ''' 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):
+        ''' Remove session records that haven't been used for a week. '''
+        now = time.time()
         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)
+                continue
             interval = now - sess
             if interval > week:
                 self.destroy(sessid)
@@ -152,3 +158,4 @@ class Sessions(BasicDatabase):
 class OneTimeKeys(BasicDatabase):
     name = 'otks'
 
+# vim: set sts ts=4 sw=4 et si :
index 3e0f5796ffa9b1919384faf43df770991b0fecf5..43702522ae4389d2d914357a3cb99fa6c9847a03 100644 (file)
@@ -1,4 +1,4 @@
-#$Id: sessions_rdbms.py,v 1.2 2004-03-31 23:08:39 richard Exp $
+#$Id: sessions_rdbms.py,v 1.8 2008-08-18 05:04:01 richard Exp $
 """This module defines a very basic store that's used by the CGI interface
 to store session and one-time-key information.
 
@@ -25,7 +25,7 @@ class BasicDatabase:
         n = self.name
         self.cursor.execute('select count(*) from %ss where %s_key=%s'%(n,
             n, self.db.arg), (infoid,))
-        return self.cursor.fetchone()[0]
+        return int(self.cursor.fetchone()[0])
 
     _marker = []
     def get(self, infoid, value, default=_marker):
@@ -77,14 +77,18 @@ class BasicDatabase:
             self.name, self.db.arg), (infoid,))
 
     def updateTimestamp(self, infoid):
-        self.cursor.execute('update %ss set %s_time=%s where %s_key=%s'%(
-            self.name, self.name, self.db.arg, self.name, self.db.arg),
-            (time.time(), infoid))
-
-    def clean(self, now):
-        """Age sessions, remove when they haven't been used for a week.
-        """
-        old = now - 60*60*24*7
+        """ 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):
+        ''' Remove session records that haven't been used for a week. '''
+        now = time.time()
+        week = 60*60*24*7
+        old = now - week
         self.cursor.execute('delete from %ss where %s_time < %s'%(self.name,
             self.name, self.db.arg), (old, ))
 
@@ -94,3 +98,4 @@ class Sessions(BasicDatabase):
 class OneTimeKeys(BasicDatabase):
     name = 'otk'
 
+# vim: set et sts=4 sw=4 :
diff --git a/roundup/backends/tsearch2_setup.py b/roundup/backends/tsearch2_setup.py
new file mode 100644 (file)
index 0000000..bc0ce48
--- /dev/null
@@ -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)
diff --git a/roundup/cgi/PageTemplates/ComputedAttribute.py b/roundup/cgi/PageTemplates/ComputedAttribute.py
deleted file mode 100644 (file)
index 7117fb4..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-class ComputedAttribute:
-    def __init__(self, callable, level):
-        self.callable = callable
-        self.level = level
-    def __of__(self, *args):
-        if self.level > 0:
-            return self.callable
-        if isinstance(self.callable, type('')):
-            return getattr(args[0], self.callable)
-        return self.callable(*args)
-
index ba315d3319aa8e4defb847e1aca78a94e04089f5..bbe7aa634ff53d09157d2163923c355343a5d42a 100644 (file)
@@ -1,37 +1,33 @@
 ##############################################################################
 #
 # 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.
-
-
-Modified for Roundup 0.5 release:
-
-- Removed all Zope-specific code (doesn't even try to import that stuff now)
-- Removed all Acquisition
-- Made traceback info more informative
-
 """
-__docformat__ = 'restructuredtext'
 
-__version__='$Revision: 1.9 $'[11:-2]
+__version__='$Revision: 1.12 $'[11:-2]
 
 import re, sys
 from TALES import Engine, CompilerError, _valid_name, NAME_RE, \
      Undefined, Default, _parse_expr
-from string import strip, split, join, replace, lstrip
+
 
 _engine = None
 def getEngine():
@@ -53,10 +49,17 @@ def installHandlers(engine):
     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)
@@ -70,6 +73,8 @@ class _SecureModuleImporter:
         __import__(module)
         return sys.modules[module]
 
+SecureModuleImporter = _SecureModuleImporter()
+
 Undefs = (Undefined, AttributeError, KeyError,
           TypeError, IndexError, Unauthorized)
 
@@ -95,9 +100,9 @@ def render(ob, ns):
 
 class SubPathExpr:
     def __init__(self, path):
-        self._path = path = split(strip(path), '/')
+        self._path = path = path.strip().split('/')
         self._base = base = path.pop(0)
-        if not _valid_name(base):
+        if base and not _valid_name(base):
             raise CompilerError, 'Invalid variable name "%s"' % base
         # Parse path
         self._dp = dp = []
@@ -123,7 +128,7 @@ class SubPathExpr:
                     path[i:i+1] = list(val)
         base = self._base
         __traceback_info__ = 'path expression "%s"'%('/'.join(self._path))
-        if base == 'CONTEXTS':
+        if base == 'CONTEXTS' or not base:
             ob = econtext.contexts
         else:
             ob = vars[base]
@@ -138,15 +143,15 @@ class PathExpr:
         self._s = expr
         self._name = name
         self._hybrid = 0
-        paths = split(expr, '|')
+        paths = expr.split('|')
         self._subexprs = []
         add = self._subexprs.append
         for i in range(len(paths)):
-            path = lstrip(paths[i])
+            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(lstrip(join(paths[i:], '|'))))
+                add(engine.compile(('|'.join(paths[i:]).lstrip())))
                 self._hybrid = 1
                 break
             add(SubPathExpr(path)._eval)
@@ -194,18 +199,18 @@ class PathExpr:
     def __repr__(self):
         return '%s:%s' % (self._name, `self._s`)
 
-            
-_interp = re.compile(r'\$(%(n)s)|\${(%(n)s(?:/%(n)s)*)}' % {'n': NAME_RE})
+
+_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 = replace(expr, '%', '%%')
+            expr = expr.replace('%', '%%')
         self._vars = vars = []
         if '$' in expr:
             parts = []
-            for exp in split(expr, '$$'):
+            for exp in expr.split('$$'):
                 if parts: parts.append('$')
                 m = _interp.search(exp)
                 while m is not None:
@@ -219,15 +224,16 @@ class StringExpr:
                     raise CompilerError, (
                         '$ must be doubled or followed by a simple path')
                 parts.append(exp)
-            expr = join(parts, '')
+            expr = ''.join(parts)
         self._expr = expr
-        
+
     def __call__(self, econtext):
         vvals = []
         for var in self._vars:
             v = var(econtext)
-            if isinstance(v, Exception):
-                raise v
+            # I hope this isn't in use anymore.
+            ## if isinstance(v, Exception):
+            ##     raise v
             vvals.append(v)
         return self._expr % tuple(vvals)
 
@@ -239,11 +245,14 @@ class StringExpr:
 
 class NotExpr:
     def __init__(self, name, expr, compiler):
-        self._s = expr = lstrip(expr)
+        self._s = expr = expr.lstrip()
         self._c = compiler.compile(expr)
-        
+
     def __call__(self, econtext):
-        return not econtext.evaluateBoolean(self._c)
+        # 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`
@@ -261,9 +270,9 @@ class DeferWrapper:
 
 class DeferExpr:
     def __init__(self, name, expr, compiler):
-        self._s = expr = lstrip(expr)
+        self._s = expr = expr.lstrip()
         self._c = compiler.compile(expr)
-        
+
     def __call__(self, econtext):
         return DeferWrapper(self._c, econtext)
 
@@ -275,39 +284,39 @@ class TraversalError:
         self.path = path
         self.name = name
 
-def restrictedTraverse(self, path, securityManager,
+
+
+def restrictedTraverse(object, path, securityManager,
                        get=getattr, has=hasattr, N=None, M=[],
                        TupleType=type(()) ):
 
     REQUEST = {'path': path}
     REQUEST['TraversalRequestNameStack'] = path = path[:] # Copy!
-    if not path[0]:
-        # If the path starts with an empty string, go to the root first.
-        self = self.getPhysicalRoot()
-        path.pop(0)
-
     path.reverse()
-    object = self
-    #print 'TRAVERSE', (object, path)
+    validate = securityManager.validate
+    __traceback_info__ = REQUEST
     done = []
     while path:
         name = path.pop()
         __traceback_info__ = TraversalError(done, name)
 
-#        if isinstance(name, TupleType):
-#            object = apply(object, name)
-#            continue
+        if isinstance(name, TupleType):
+            object = object(*name)
+            continue
 
-#        if name[0] == '_':
-#            # Never allowed in a URL.
-#            raise AttributeError, name
+        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 = get(object, name, M)
-#       print '...', (object, name, M, o)
+        o = guarded_getattr(object, name, M)
         if o is M:
             # Try an item.
-#           print '... try an item'
             try:
                 # XXX maybe in Python 2.2 we can just check whether
                 # the object has the attribute "__getitem__"
@@ -319,18 +328,16 @@ def restrictedTraverse(self, path, securityManager,
                     # Try to re-raise the original attribute error.
                     # XXX I think this only happens with
                     # ExtensionClass instances.
-                    get(object, name)
+                    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.
-                    get(object, name)
+                    guarded_getattr(object, name)
                 raise
-        #print '... object is now', `o`
-        object = o
         done.append((name, o))
+        object = o
 
     return object
-
diff --git a/roundup/cgi/PageTemplates/GlobalTranslationService.py b/roundup/cgi/PageTemplates/GlobalTranslationService.py
new file mode 100644 (file)
index 0000000..31944ff
--- /dev/null
@@ -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
index d4bb542509738ab7edd0df46ab94626841a91a15..4e4908995bec1e326bc8500dd564333c19156e75 100644 (file)
@@ -17,7 +17,7 @@ class MultiMapping:
             raise KeyError, key
         return default
     def __len__(self):
-        return reduce(operator.add, [len(x) for x in stores], 0)
+        return reduce(operator.add, [len(x) for x in self.stores], 0)
     def push(self, store):
         self.stores.append(store)
     def pop(self):
index ddb097ca2b87435c7eb66b46e9911cfb3d46ada7..7a420e33c62bed7fe1ee3a02b6a4d35090a3e393 100755 (executable)
@@ -1,42 +1,40 @@
 ##############################################################################
 #
 # 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.
-
-
-Modified for Roundup 0.5 release:
-
-- changed imports to import from roundup.cgi
-
 """
-__docformat__ = 'restructuredtext'
 
-__version__='$Revision: 1.4 $'[11:-2]
+__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
-from roundup.cgi.TAL.TALInterpreter import TALInterpreter
+# Do not use cStringIO here!  It's not unicode aware. :(
+from roundup.cgi.TAL.TALInterpreter import TALInterpreter, FasterStringIO
 from Expressions import getEngine
-from string import join, strip, rstrip, split, replace, lower, find
-from cStringIO import StringIO
+
 
 class PageTemplate:
     "Page Templates using TAL, TALES, and METAL"
-     
+
     content_type = 'text/html'
     expand = 0
     _v_errors = ()
@@ -48,6 +46,11 @@ class PageTemplate:
     _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)
@@ -71,7 +74,7 @@ class PageTemplate:
                 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:
@@ -81,7 +84,7 @@ class PageTemplate:
 
         if self._v_errors:
             raise PTRuntimeError, 'Page Template %s has errors.' % self.id
-        output = StringIO()
+        output = self.StringIO()
         c = self.pt_getContext()
         c.update(extra_context)
 
@@ -107,7 +110,7 @@ class PageTemplate:
             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()
@@ -132,7 +135,7 @@ class PageTemplate:
     def write(self, text):
         assert type(text) is type('')
         if text[:len(self._error_start)] == self._error_start:
-            errend = find(text, '-->')
+            errend = text.find('-->')
             if errend >= 0:
                 text = text[errend + 4:]
         if self._text != text:
@@ -140,8 +143,7 @@ class PageTemplate:
         self._cook()
 
     def read(self):
-        if not self._v_cooked:
-            self._cook()
+        self._cook_check()
         if not self._v_errors:
             if not self.expand:
                 return self._text
@@ -151,11 +153,15 @@ class PageTemplate:
                 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,
-                                      join(self._v_errors, '\n '),
+                                      '\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.
 
@@ -187,7 +193,7 @@ class PageTemplate:
 class _ModuleImporter:
     def __getitem__(self, module):
         mod = __import__(module)
-        path = split(module, '.')
+        path = module.split('.')
         for name in path[1:]:
             mod = getattr(mod, name)
         return mod
index 4e6bd76d0a20983118d3ab701377009ce5ba9948..90b401ec3857c0dfd5122ebdec3d0caecdf42d1a 100644 (file)
@@ -1,14 +1,14 @@
 ##############################################################################
 #
 # 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.
 """
-__docformat__ = 'restructuredtext'
 
-__version__='$Revision: 1.2 $'[11:-2]
+__version__='$Revision: 1.3 $'[11:-2]
 
 import TALES
 from Expressions import restrictedTraverse, Undefs, getSecurityManager
-from string import split
 
 class Iterator(TALES.Iterator):
     def __bobo_traverse__(self, REQUEST, name):
@@ -37,7 +35,7 @@ class Iterator(TALES.Iterator):
         if name is None:
             return ob1 == ob2
         if isinstance(name, type('')):
-            name = split(name, '/')
+            name = name.split('/')
         name = filter(None, name)
         securityManager = getSecurityManager()
         try:
@@ -46,4 +44,3 @@ class Iterator(TALES.Iterator):
         except Undefs:
             return 0
         return ob1 == ob2
-
index 44a0491f8ddf82bea39cbed87b7dcfa3f992c456..35a68081194586e651b62afd38dccbf97b5ad6c1 100644 (file)
@@ -1,29 +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
-# 
+#
 ##############################################################################
+# Modified for Roundup:
+# 
+# 1. more informative traceback info
 
 """Generic Python Expression Handler
-
-Modified for Roundup 0.5 release:
-
-- more informative traceback info
-
 """
-__docformat__ = 'restructuredtext'
 
-__version__='$Revision: 1.5 $'[11:-2]
+__version__='$Revision: 1.6 $'[11:-2]
 
 from TALES import CompilerError
-from string import strip, split, join, replace, lstrip
 from sys import exc_info
 
 class getSecurityManager:
@@ -34,10 +30,10 @@ class getSecurityManager:
 
 class PythonExpr:
     def __init__(self, name, expr, engine):
-        self.expr = expr = replace(strip(expr), '\n', ' ')
+        self.expr = expr = expr.strip().replace('\n', ' ')
         try:
             d = {}
-            exec 'def f():\n return %s\n' % strip(expr) in d
+            exec 'def f():\n return %s\n' % expr.strip() in d
             self._f = d['f']
         except:
             raise CompilerError, ('Python expression error:\n'
@@ -50,25 +46,26 @@ class PythonExpr:
             if vname[0] not in '$_':
                 vnames.append(vname)
 
-    def _bind_used_names(self, econtext):
+    def _bind_used_names(self, econtext, _marker=[]):
         # Bind template variables
-        names = {}
+        names = {'CONTEXTS': econtext.contexts}
         vars = econtext.vars
         getType = econtext.getCompiler().getTypes().get
         for vname in self._f_varnames:
-            has, val = vars.has_get(vname)
-            if not has:
+            val = vars.get(vname, _marker)
+            if val is _marker:
                 has = val = getType(vname)
                 if has:
                     val = ExprTypeProxy(vname, val, econtext)
-            if has:
+                    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))        
+        f.func_globals.update(self._bind_used_names(econtext))
         return f()
 
     def __str__(self):
index ac8aec9e638160a6c2bc998c4775b8a997f3e5f8..1f0423ce0cdce428216b46913792fdb469df2553 100644 (file)
@@ -1,30 +1,34 @@
 ##############################################################################
 #
 # 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
-
-Modified for Roundup 0.5 release:
-
-- changed imports to import from roundup.cgi
 """
-__docformat__ = 'restructuredtext'
 
-__version__='$Revision: 1.6 $'[11:-2]
+__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('')
 
@@ -48,8 +52,6 @@ class Default:
     '''Retain Default'''
 Default = Default()
 
-_marker = []
-
 class SafeMapping(MultiMapping):
     '''Mapping with security declarations and limited method exposure.
 
@@ -64,19 +66,18 @@ class SafeMapping(MultiMapping):
     _push = MultiMapping.push
     _pop = MultiMapping.pop
 
-    def has_get(self, key, _marker=[]):
-        v = self.get(key, _marker)
-        return v is not _marker, v
 
 class Iterator(ZTUtils.Iterator):
     def __init__(self, name, seq, context):
         ZTUtils.Iterator.__init__(self, seq)
         self.name = name
-        self._context = context
+        self._context_ref = ref(context)
 
     def next(self):
         if ZTUtils.Iterator.next(self):
-            self._context.setLocal(self.name, self.item)
+            context = self._context_ref()
+            if context is not None:
+                context.setLocal(self.name, self.item)
             return 1
         return 0
 
@@ -138,7 +139,7 @@ class Engine:
             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:
@@ -223,8 +224,7 @@ class Context:
             expression = self._compiler.compile(expression)
         __traceback_supplement__ = (
             TALESTracebackSupplement, self, expression)
-        v = expression(self)
-        return v
+        return expression(self)
 
     evaluateValue = evaluate
     evaluateBoolean = evaluate
@@ -233,7 +233,10 @@ class Context:
         text = self.evaluate(expr)
         if text is Default or text is None:
             return text
-        return str(text)
+        if isinstance(text, unicode):
+            return text
+        else:
+            return ustr(text)
 
     def evaluateStructure(self, expr):
         return self.evaluate(expr)
@@ -256,7 +259,15 @@ class Context:
     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"""
@@ -272,12 +283,10 @@ class TALESTracebackSupplement:
         data = self.context.contexts.copy()
         s = pprint.pformat(data)
         if not as_html:
-            return '   - Names:\n      %s' % string.replace(s, '\n', '\n      ')
+            return '   - Names:\n      %s' % s.replace('\n', '\n      ')
         else:
             from cgi import escape
             return '<b>Names:</b><pre>%s</pre>' % (escape(s))
-        return None
-
 
 
 class SimpleExpr:
@@ -289,4 +298,3 @@ class SimpleExpr:
         return self._name, self._expr
     def __repr__(self):
         return '<SimpleExpr %s %s>' % (self._name, `self._expr`)
-
index c0677c9677d217958dc0c2daa19e5baf30e37a6e..bd423709fd1a4a03f0a98b0747a596881d286f9c 100644 (file)
@@ -1,22 +1,21 @@
 ##############################################################################
 #
 # 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.2 2004-02-11 23:55:09 richard Exp $'''
-__docformat__ = 'restructuredtext'
+$Id: __init__.py,v 1.3 2004-05-21 05:56:46 richard Exp $'''
 __version__='$$'[11:-2]
 
 
diff --git a/roundup/cgi/TAL/DummyEngine.py b/roundup/cgi/TAL/DummyEngine.py
new file mode 100644 (file)
index 0000000..9c56416
--- /dev/null
@@ -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()
index e8ae444ed0e98098f56db3c41b963f03e2a400e9..bfe76df5d9b51c4dd4bb350f36c8cf0e8a14731b 100644 (file)
@@ -1,5 +1,4 @@
 """A parser for HTML and XHTML."""
-__docformat__ = 'restructuredtext'
 
 # This file is based on sgmllib.py, but the API is slightly different.
 
@@ -11,7 +10,6 @@ __docformat__ = 'restructuredtext'
 
 import markupbase
 import re
-import string
 
 # Regular expressions used for parsing
 
@@ -261,7 +259,7 @@ class HTMLParser(markupbase.ParserBase):
         match = tagfind.match(rawdata, i+1)
         assert match, 'unexpected call to parse_starttag()'
         k = match.end()
-        self.lasttag = tag = string.lower(rawdata[i+1:k])
+        self.lasttag = tag = rawdata[i+1:k].lower()
 
         while k < endpos:
             m = attrfind.match(rawdata, k)
@@ -274,16 +272,16 @@ class HTMLParser(markupbase.ParserBase):
                  attrvalue[:1] == '"' == attrvalue[-1:]:
                 attrvalue = attrvalue[1:-1]
                 attrvalue = self.unescape(attrvalue)
-            attrs.append((string.lower(attrname), attrvalue))
+            attrs.append((attrname.lower(), attrvalue))
             k = m.end()
 
-        end = string.strip(rawdata[k:endpos])
+        end = rawdata[k:endpos].strip()
         if end not in (">", "/>"):
             lineno, offset = self.getpos()
             if "\n" in self.__starttag_text:
-                lineno = lineno + string.count(self.__starttag_text, "\n")
+                lineno = lineno + self.__starttag_text.count("\n")
                 offset = len(self.__starttag_text) \
-                         - string.rfind(self.__starttag_text, "\n")
+                         - self.__starttag_text.rfind("\n")
             else:
                 offset = offset + len(self.__starttag_text)
             self.error("junk characters in start tag: %s"
@@ -340,7 +338,7 @@ class HTMLParser(markupbase.ParserBase):
         match = endtagfind.match(rawdata, i) # </ + tag + >
         if not match:
             self.error("bad end tag: %s" % `rawdata[i:j]`)
-        tag = string.lower(match.group(1))
+        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
@@ -396,9 +394,9 @@ class HTMLParser(markupbase.ParserBase):
     def unescape(self, s):
         if '&' not in s:
             return s
-        s = string.replace(s, "&lt;", "<")
-        s = string.replace(s, "&gt;", ">")
-        s = string.replace(s, "&apos;", "'")
-        s = string.replace(s, "&quot;", '"')
-        s = string.replace(s, "&amp;", "&") # Must be last
+        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
index f1582f2f39cb46b12bedc1c3ca4e521f67a93b54..86de21890a9cc97ce05e1e1a3cd36d0504bf9471 100644 (file)
@@ -2,25 +2,25 @@
 #
 # 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
-# 
+# FOR A PARTICULAR PURPOSE.
+#
 ##############################################################################
-"""Parse HTML and compile to TALInterpreter intermediate code.
 """
-__docformat__ = 'restructuredtext'
+Parse HTML and compile to TALInterpreter intermediate code.
+"""
 
 import sys
-import string
 
 from TALGenerator import TALGenerator
-from TALDefs import ZOPE_METAL_NS, ZOPE_TAL_NS, METALError, TALError
 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
@@ -75,7 +75,7 @@ class NestingError(HTMLParseError):
                        % (tagstack[0], endtag))
             else:
                 msg = ('Open tags <%s> do not match close tag </%s>'
-                       % (string.join(tagstack, '>, <'), endtag))
+                       % ('>, <'.join(tagstack), endtag))
         else:
             msg = 'No tags are open to match </%s>' % endtag
         HTMLParseError.__init__(self, msg, position)
@@ -107,13 +107,20 @@ class HTMLTALParser(HTMLParser):
         self.gen = gen
         self.tagstack = []
         self.nsstack = []
-        self.nsdict = {'tal': ZOPE_TAL_NS, 'metal': ZOPE_METAL_NS}
+        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()
-        self.parseString(data)
+        try:
+            self.parseString(data)
+        except TALError, e:
+            e.setFile(file)
+            raise
 
     def parseString(self, data):
         self.feed(data)
@@ -133,9 +140,14 @@ class HTMLTALParser(HTMLParser):
     def handle_starttag(self, tag, attrs):
         self.close_para_tags(tag)
         self.scan_xmlns(attrs)
-        tag, attrlist, taldict, metaldict = self.process_ns(tag, 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,
+        self.gen.emitStartElement(tag, attrlist, taldict, metaldict, i18ndict,
                                   self.getpos())
         if tag in EMPTY_HTML_TAGS:
             self.implied_endtag(tag, -1)
@@ -143,14 +155,19 @@ class HTMLTALParser(HTMLParser):
     def handle_startendtag(self, tag, attrs):
         self.close_para_tags(tag)
         self.scan_xmlns(attrs)
-        tag, attrlist, taldict, metaldict = self.process_ns(tag, 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,
-                                      self.getpos())
+                                      i18ndict, self.getpos())
             self.gen.emitEndElement(tag, implied=-1)
         else:
             self.gen.emitStartElement(tag, attrlist, taldict, metaldict,
-                                      self.getpos(), isend=1)
+                                      i18ndict, self.getpos(), isend=1)
         self.pop_xmlns()
 
     def handle_endtag(self, tag):
@@ -236,7 +253,7 @@ class HTMLTALParser(HTMLParser):
     def scan_xmlns(self, attrs):
         nsnew = {}
         for key, value in attrs:
-            if key[:6] == "xmlns:":
+            if key.startswith("xmlns:"):
                 nsnew[key[6:]] = value
         if nsnew:
             self.nsstack.append(self.nsdict)
@@ -250,10 +267,10 @@ class HTMLTALParser(HTMLParser):
 
     def fixname(self, name):
         if ':' in name:
-            prefix, suffix = string.split(name, ':', 1)
+            prefix, suffix = name.split(':', 1)
             if prefix == 'xmlns':
                 nsuri = self.nsdict.get(suffix)
-                if nsuri in (ZOPE_TAL_NS, ZOPE_METAL_NS):
+                if nsuri in (ZOPE_TAL_NS, ZOPE_METAL_NS, ZOPE_I18N_NS):
                     return name, name, prefix
             else:
                 nsuri = self.nsdict.get(prefix)
@@ -261,12 +278,15 @@ class HTMLTALParser(HTMLParser):
                     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
@@ -284,7 +304,12 @@ class HTMLTALParser(HTMLParser):
                     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
+        return name, attrlist, taldict, metaldict, i18ndict
index f49622fe6b0e9a742a33bb7854829e32c4f4d1c5..eb710956d6ae62296afcd79e29d399681e4eebbf 100644 (file)
@@ -2,37 +2,44 @@
 #
 # 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
-# 
+# FOR A PARTICULAR PURPOSE.
+#
 ##############################################################################
-"""Common definitions used by TAL and METAL compilation an transformation.
+# Modifications for Roundup:
+# 1. commented out ITALES references
+"""
+Common definitions used by TAL and METAL compilation an transformation.
 """
-__docformat__ = 'restructuredtext'
 
 from types import ListType, TupleType
 
-TAL_VERSION = "1.3.2"
+#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"
 
-NAME_RE = "[a-zA-Z_][a-zA-Z0-9_]*"
+# 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"
+    "slot",
     ]
 
 KNOWN_TAL_ATTRIBUTES = [
@@ -47,6 +54,16 @@ KNOWN_TAL_ATTRIBUTES = [
     "tal tag",
     ]
 
+KNOWN_I18N_ATTRIBUTES = [
+    "translate",
+    "domain",
+    "target",
+    "source",
+    "attributes",
+    "data",
+    "name",
+    ]
+
 class TALError(Exception):
 
     def __init__(self, msg, position=(None, None)):
@@ -54,6 +71,10 @@ class TALError(Exception):
         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
@@ -61,6 +82,8 @@ class TALError(Exception):
             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):
@@ -69,8 +92,14 @@ class METALError(TALError):
 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__
@@ -81,20 +110,24 @@ class ErrorInfo:
         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):
+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`)
+            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`)
+            raise TALError("Duplicate attribute name in attributes: " + `part`)
         dict[name] = expr
     return dict
 
@@ -110,11 +143,10 @@ def parseSubstitution(arg, position=(None, None)):
 def splitParts(arg):
     # Break in pieces at undoubled semicolons and
     # change double semicolons to singles:
-    import string
-    arg = string.replace(arg, ";;", "\0")
-    parts = string.split(arg, ';')
-    parts = map(lambda s, repl=string.replace: repl(s, "\0", ";"), parts)
-    if len(parts) > 1 and not string.strip(parts[-1]):
+    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
 
@@ -139,7 +171,23 @@ def getProgramVersion(program):
             return version
     return None
 
-import cgi
-def quote(s, escape=cgi.escape):
-    return '"%s"' % escape(s, 1)
-del cgi
+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
index 8a4f55becd251344e3506f06c03d16a7ebca47c1..b3cda9ae457e18d17a135ec1c8def3bf27d6797e 100644 (file)
@@ -2,39 +2,57 @@
 #
 # 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
-# 
+# FOR A PARTICULAR PURPOSE.
+#
 ##############################################################################
-"""Code generator for TALInterpreter intermediate code.
 """
-__docformat__ = 'restructuredtext'
+Code generator for TALInterpreter intermediate code.
+"""
 
-import string
 import re
 import cgi
 
-from TALDefs import *
+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 = {}
@@ -45,6 +63,8 @@ class TALGenerator:
         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
@@ -54,7 +74,7 @@ class TALGenerator:
     def optimize(self, program):
         output = []
         collect = []
-        rawseen = cursor = 0
+        cursor = 0
         if self.xml:
             endsep = "/>"
         else:
@@ -83,9 +103,15 @@ class TALGenerator:
                 # instructions to be joined together.
                 output.append(self.optimizeArgsList(item))
                 continue
-            text = string.join(collect, "")
+            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 = string.rfind(text, "\n")
+                i = text.rfind("\n")
                 if i >= 0:
                     i = len(text) - (i + 1)
                     output.append(("rawtextColumn", (text, i)))
@@ -93,7 +119,6 @@ class TALGenerator:
                     output.append(("rawtextOffset", (text, len(text))))
             if opcode != None:
                 output.append(self.optimizeArgsList(item))
-            rawseen = cursor+1
             collect = []
         return self.optimizeCommonTriple(output)
 
@@ -103,9 +128,28 @@ class TALGenerator:
         else:
             return item[0], tuple(item[1:])
 
-    actionIndex = {"replace":0, "insert":1, "metal":2, "tal":3, "xmlns":4,
-                   0: 0, 1: 1, 2: 2, 3: 3, 4: 4}
+    # 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
@@ -116,18 +160,15 @@ class TALGenerator:
             if len(item) > 2:
                 opt = 0
                 name, value, action = item[:3]
-                action = self.actionIndex[action]
                 attrlist[i] = (name, value, action) + item[3:]
             else:
                 if item[1] is None:
                     s = item[0]
                 else:
-                    s = "%s=%s" % (item[0], quote(item[1]))
+                    s = '%s="%s"' % (item[0], TALDefs.attrEscape(item[1]))
                 attrlist[i] = item[0], s
-            if item[1] is None:
-                new.append(" " + item[0])
-            else:
-                new.append(" %s=%s" % (item[0], quote(item[1])))
+                new.append(" " + s)
+        # if no non-optimizable attributes were found, convert to plain text
         if opt:
             new.append(end)
             collect.extend(new)
@@ -139,9 +180,9 @@ class TALGenerator:
         output = program[:2]
         prev2, prev1 = output
         for item in program[2:]:
-            if (  item[0] == "beginScope"
-                  and prev1[0] == "setPosition"
-                  and prev2[0] == "rawtextColumn"):
+            if ( item[0] == "beginScope"
+                 and prev1[0] == "setPosition"
+                 and prev2[0] == "rawtextColumn"):
                 position = output.pop()[1]
                 text, column = output.pop()[1]
                 prev1 = None, None
@@ -215,7 +256,7 @@ class TALGenerator:
         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)
 
@@ -223,7 +264,7 @@ class TALGenerator:
         self.emitRawText(cgi.escape(text))
 
     def emitDefines(self, defines):
-        for part in splitParts(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:
@@ -278,9 +319,56 @@ class TALGenerator:
             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 = string.strip(macroName)
+        macroName = macroName.strip()
         if self.macros.has_key(macroName):
             raise METALError("duplicate macro definition: %s" % `macroName`,
                              self.position)
@@ -299,7 +387,7 @@ class TALGenerator:
 
     def emitDefineSlot(self, slotName):
         program = self.popProgram()
-        slotName = string.strip(slotName)
+        slotName = slotName.strip()
         if not re.match('%s$' % NAME_RE, slotName):
             raise METALError("invalid slot name: %s" % `slotName`,
                              self.position)
@@ -307,7 +395,7 @@ class TALGenerator:
 
     def emitFillSlot(self, slotName):
         program = self.popProgram()
-        slotName = string.strip(slotName)
+        slotName = slotName.strip()
         if self.slots.has_key(slotName):
             raise METALError("duplicate fill-slot name: %s" % `slotName`,
                              self.position)
@@ -338,7 +426,7 @@ class TALGenerator:
                 self.program[i] = ("rawtext", text[:m.start()])
                 collect.append(m.group())
         collect.reverse()
-        return string.join(collect, "")
+        return "".join(collect)
 
     def unEmitNewlineWhitespace(self):
         collect = []
@@ -357,7 +445,7 @@ class TALGenerator:
                 break
             text, rest = m.group(1, 2)
             collect.reverse()
-            rest = rest + string.join(collect, "")
+            rest = rest + "".join(collect)
             del self.program[i:]
             if text:
                 self.emit("rawtext", text)
@@ -365,23 +453,30 @@ class TALGenerator:
         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):
-                item = item[:2] + ("replace", repldict[key])
+                expr, xlat, msgid = repldict[key]
+                item = item[:2] + ("replace", expr, xlat, msgid)
                 del repldict[key]
             newlist.append(item)
-        for key, value in repldict.items(): # Add dynamic-only attributes
-            item = (key, None, "insert", value)
-            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,
+    def emitStartElement(self, name, attrlist, taldict, metaldict, i18ndict,
                          position=(None, None), isend=0):
-        if not taldict and not metaldict:
+        if not taldict and not metaldict and not i18ndict:
             # Handle the simple, common case
             self.emitStartTag(name, attrlist, isend)
             self.todoPush({})
@@ -391,18 +486,24 @@ class TALGenerator:
 
         self.position = position
         for key, value in taldict.items():
-            if key not in KNOWN_TAL_ATTRIBUTES:
+            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 KNOWN_METAL_ATTRIBUTES:
+            if key not in TALDefs.KNOWN_METAL_ATTRIBUTES:
                 raise METALError("bad METAL attribute: " + `key`,
-                position)
+                                 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")
@@ -417,13 +518,36 @@ class TALGenerator:
         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 content and replace:
-            raise TALError("content and replace are mutually exclusive",
-                           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:
@@ -441,8 +565,8 @@ class TALGenerator:
                 self.inMacroUse = 0
         else:
             if fillSlot:
-                raise METALError("fill-slot must be within a use-macro",
-                                   position)
+                raise METALError("fill-slot must be within a use-macro",
+                                 position)
         if not self.inMacroUse:
             if defineMacro:
                 self.pushProgram()
@@ -459,13 +583,29 @@ class TALGenerator:
                 self.inMacroUse = 1
             if defineSlot:
                 if not self.inMacroDef:
-                    raise METALError(
+                    raise METALError(
                         "define-slot must be within a define-macro",
                         position)
                 self.pushProgram()
                 todo["defineSlot"] = defineSlot
 
-        if taldict:
+        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]
@@ -493,18 +633,60 @@ class TALGenerator:
             if repeatWhitespace:
                 self.emitText(repeatWhitespace)
         if content:
-            todo["content"] = content
-        if replace:
-            todo["replace"] = replace
+            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:
-            repldict = parseAttributeReplacements(attrsubst)
+        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():
-                repldict[key] = self.compileExpression(value)
+                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:
@@ -513,7 +695,11 @@ class TALGenerator:
         self.emitStartTag(name, self.replaceAttrs(attrlist, repldict), isend)
         if optTag:
             self.pushProgram()
-        if content:
+        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
@@ -543,6 +729,10 @@ class TALGenerator:
         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:
@@ -554,14 +744,58 @@ class TALGenerator:
             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:
@@ -570,6 +804,10 @@ class TALGenerator:
             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:
@@ -579,6 +817,54 @@ class TALGenerator:
         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()
index 5f9c67fa78f134121ae958bc6d3c82a58810821f..3094848498d1cbf17691a01aff4410c364db3a0b 100644 (file)
@@ -2,32 +2,35 @@
 #
 # 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
-# 
+# FOR A PARTICULAR PURPOSE.
+#
 ##############################################################################
-"""Interpreter for a pre-compiled TAL program.
+# Modifications for Roundup:
+# 1. implemented ustr as str
+"""
+Interpreter for a pre-compiled TAL program.
 """
-__docformat__ = 'restructuredtext'
 
 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
 
-try:
-    from cStringIO import StringIO
-except ImportError:
-    from StringIO import StringIO
-
-from TALDefs import quote, TAL_VERSION, TALError, METALError
+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
@@ -40,13 +43,43 @@ BOOLEAN_HTML_ATTRS = [
     "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",
-]
+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):
 
@@ -60,16 +93,18 @@ class AltTALGenerator(TALGenerator):
 
     def emit(self, *args):
         if self.enabled:
-            apply(TALGenerator.emit, (self,) + args)
+            TALGenerator.emit(self, *args)
 
-    def emitStartElement(self, name, attrlist, taldict, metaldict,
+    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, position, isend)
+                                      taldict, metaldict, i18ndict,
+                                      position, isend)
 
     def replaceAttrs(self, attrlist, repldict):
         if self.enabled and self.repldict:
@@ -82,10 +117,10 @@ class TALInterpreter:
 
     def __init__(self, program, macros, engine, stream=None,
                  debug=0, wrap=60, metal=1, tal=1, showtal=-1,
-                 strictinsert=1, stackLimit=100):
+                 strictinsert=1, stackLimit=100, i18nInterpolate=1):
         self.program = program
         self.macros = macros
-        self.engine = engine
+        self.engine = engine # Execution engine (aka context)
         self.Default = engine.getDefault()
         self.stream = stream or sys.stdout
         self._stream_write = self.stream.write
@@ -107,28 +142,36 @@ class TALInterpreter:
         self.endsep = "/>"
         self.endlen = len(self.endsep)
         self.macroStack = []
-        self.popMacro = self.macroStack.pop
         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.scopeLevel, self.level, self.i18nContext)
 
     def restoreState(self, state):
-        (self.position, self.col, self.stream, scopeLevel, level) = 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) = state
+        (dummy, self.col, self.stream, scopeLevel, level, i18n) = state
         self._stream_write = self.stream.write
         assert self.level == level
         assert self.scopeLevel == scopeLevel
@@ -137,28 +180,25 @@ class TALInterpreter:
         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.macroStack.append([macroName, slots, entering, self.i18nContext])
 
-    def macroContext(self, what):
-        macroStack = self.macroStack
-        i = len(macroStack)
-        while i > 0:
-            i = i-1
-            if macroStack[i][0] == what:
-                return i
-        return -1
+    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):
+    def stream_write(self, s,
+                     len=len):
         self._stream_write(s)
         i = s.rfind('\n')
         if i < 0:
@@ -168,6 +208,16 @@ class TALInterpreter:
 
     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
@@ -175,8 +225,8 @@ class TALInterpreter:
         try:
             if self.debug:
                 for (opcode, args) in program:
-                    s = "%sdo_%s%s\n" % ("    "*self.level, opcode,
-                                      repr(args))
+                    s = "%sdo_%s(%s)\n" % ("    "*self.level, opcode,
+                                           repr(args))
                     if len(s) > 80:
                         s = s[:76] + "...\n"
                     sys.stderr.write(s)
@@ -221,10 +271,9 @@ class TALInterpreter:
         # for start tags with no attributes; those are optimized down
         # to rawtext events.  Hence, there is no special "fast path"
         # for that case.
-        _stream_write = self._stream_write
-        _stream_write("<" + name)
-        namelen = _len(name)
-        col = self.col + namelen + 1
+        L = ["<", name]
+        append = L.append
+        col = self.col + _len(name) + 1
         wrap = self.wrap
         align = col + 1
         if align >= wrap/2:
@@ -235,20 +284,28 @@ class TALInterpreter:
                 if _len(item) == 2:
                     name, s = item
                 else:
-                    ok, name, s = attrAction(self, item)
+                    # 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):
-                    _stream_write("\n" + " "*align)
+                    append("\n")
+                    append(" "*align)
                     col = align + slen
                 else:
-                    s = " " + s
+                    append(" ")
                     col = col + 1 + slen
-                _stream_write(s)
-            _stream_write(end)
+                append(s)
+            append(end)
+            self._stream_write("".join(L))
             col = col + endlen
         finally:
             self.col = col
@@ -256,10 +313,10 @@ class TALInterpreter:
 
     def attrAction(self, item):
         name, value, action = item[:3]
-        if action == 1 or (action > 1 and not self.showtal):
+        if action == 'insert':
             return 0, name, value
         macs = self.macroStack
-        if action == 2 and self.metal and macs:
+        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
@@ -273,7 +330,7 @@ class TALInterpreter:
                 name = prefix + "use-macro"
                 value = macs[-1][0] # Macro name
             elif suffix == "define-slot":
-                name = prefix + "slot"
+                name = prefix + "fill-slot"
             elif suffix == "fill-slot":
                 pass
             else:
@@ -282,43 +339,52 @@ class TALInterpreter:
         if value is None:
             value = name
         else:
-            value = "%s=%s" % (name, quote(value))
+            value = '%s="%s"' % (name, attrEscape(value))
         return 1, name, value
 
     def attrAction_tal(self, item):
         name, value, action = item[:3]
-        if action > 1:
-            return self.attrAction(item)
         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 == 1: # Cancelled insert
+                if action == 'insert': # Cancelled insert
                     ok = 0
             elif evalue:
                 value = None
             else:
                 ok = 0
-        else:
+        elif expr is not None:
             evalue = self.engine.evaluateText(item[3])
             if evalue is self.Default:
-                if action == 1: # Cancelled insert
+                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
-            value = "%s=%s" % (name, quote(value))
+            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 = StringIO()
+        self.stream = stream = self.StringIO()
         self._stream_write = stream.write
         self.interpret(start)
         self.restoreOutputState(state)
@@ -328,7 +394,7 @@ class TALInterpreter:
                   omit=0):
         if tag_ns and not self.showtal:
             return self.no_tag(start, program)
-            
+
         self.interpret(start)
         if not isend:
             self.interpret(program)
@@ -345,18 +411,11 @@ class TALInterpreter:
             self.do_optTag(stuff)
     bytecode_handlers["optTag"] = do_optTag
 
-    def dumpMacroStack(self, prefix, suffix, value):
-        sys.stderr.write("+---- %s%s = %s\n" % (prefix, suffix, value))
-        for i in range(len(self.macroStack)):
-            what, macroName, slots = self.macroStack[i]
-            sys.stderr.write("| %2d. %-12s %-12s %s\n" %
-                             (i, what, macroName, slots and slots.keys()))
-        sys.stderr.write("+--------------------------------------\n")
-
     def do_rawtextBeginScope(self, (s, col, position, closeprev, dict)):
         self._stream_write(s)
         self.col = col
-        self.do_setPosition(position)
+        self.position = position
+        self.engine.setPosition(position)
         if closeprev:
             engine = self.engine
             engine.endScope()
@@ -368,8 +427,9 @@ class TALInterpreter:
     def do_rawtextBeginScope_tal(self, (s, col, position, closeprev, dict)):
         self._stream_write(s)
         self.col = col
-        self.do_setPosition(position)
         engine = self.engine
+        self.position = position
+        engine.setPosition(position)
         if closeprev:
             engine.endScope()
             engine.beginScope()
@@ -406,6 +466,19 @@ class TALInterpreter:
         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])
 
@@ -425,6 +498,65 @@ class TALInterpreter:
             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])
 
@@ -435,7 +567,7 @@ class TALInterpreter:
         if structure is self.Default:
             self.interpret(block)
             return
-        text = str(structure)
+        text = ustr(structure)
         if not (repldict or self.strictinsert):
             # Take a shortcut, no error checking
             self.stream_write(text)
@@ -448,7 +580,7 @@ class TALInterpreter:
 
     def insertHTMLStructure(self, text, repldict):
         from HTMLTALParser import HTMLTALParser
-        gen = AltTALGenerator(repldict, self.engine, 0)
+        gen = AltTALGenerator(repldict, self.engine.getCompiler(), 0)
         p = HTMLTALParser(gen) # Raises an exception if text is invalid
         p.parseString(text)
         program, macros = p.getCode()
@@ -456,7 +588,7 @@ class TALInterpreter:
 
     def insertXMLStructure(self, text, repldict):
         from TALParser import TALParser
-        gen = AltTALGenerator(repldict, self.engine, 0)
+        gen = AltTALGenerator(repldict, self.engine.getCompiler(), 0)
         p = TALParser(gen)
         gen.enable(0)
         p.parseFragment('<!DOCTYPE foo PUBLIC "foo" "bar"><foo>')
@@ -476,6 +608,15 @@ class TALInterpreter:
             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
@@ -498,6 +639,7 @@ class TALInterpreter:
             if not entering:
                 macs.append(None)
                 self.interpret(macro)
+                assert macs[-1] is None
                 macs.pop()
                 return
         self.interpret(macro)
@@ -520,12 +662,11 @@ class TALInterpreter:
                 raise METALError("macro %s has incompatible mode %s" %
                                  (`macroName`, `mode`), self.position)
         self.pushMacro(macroName, compiledSlots)
-        saved_source = self.sourceFile
-        saved_position = self.position  # Used by Boa Constructor
+        prev_source = self.sourceFile
         self.interpret(macro)
-        if self.sourceFile != saved_source:
-            self.engine.setSourceFile(saved_source)
-            self.sourceFile = saved_source
+        if self.sourceFile != prev_source:
+            self.engine.setSourceFile(prev_source)
+            self.sourceFile = prev_source
         self.popMacro()
     bytecode_handlers["useMacro"] = do_useMacro
 
@@ -541,21 +682,18 @@ class TALInterpreter:
             return
         macs = self.macroStack
         if macs and macs[-1] is not None:
-            saved_source = self.sourceFile
-            saved_position = self.position  # Used by Boa Constructor
             macroName, slots = self.popMacro()[:2]
             slot = slots.get(slotName)
             if slot is not None:
+                prev_source = self.sourceFile
                 self.interpret(slot)
-                if self.sourceFile != saved_source:
-                    self.engine.setSourceFile(saved_source)
-                    self.sourceFile = saved_source
+                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)
-            if len(macs) == 1:
-                self.interpret(block)
-                return
+            # Falling out of the 'if' allows the macro to be interpreted.
         self.interpret(block)
     bytecode_handlers["defineSlot"] = do_defineSlot
 
@@ -564,7 +702,7 @@ class TALInterpreter:
 
     def do_onError_tal(self, (block, handler)):
         state = self.saveState()
-        self.stream = stream = StringIO()
+        self.stream = stream = self.StringIO()
         self._stream_write = stream.write
         try:
             self.interpret(block)
@@ -597,24 +735,24 @@ class TALInterpreter:
     bytecode_handlers_tal["optTag"] = do_optTag_tal
 
 
-def test():
-    from driver import FILE, parsefile
-    from DummyEngine import DummyEngine
-    try:
-        opts, args = getopt.getopt(sys.argv[1:], "")
-    except getopt.error, msg:
-        print msg
-        sys.exit(2)
-    if args:
-        file = args[0]
-    else:
-        file = FILE
-    doc = parsefile(file)
-    compiler = TALCompiler(doc)
-    program, macros = compiler()
-    engine = DummyEngine()
-    interpreter = TALInterpreter(program, macros, engine)
-    interpreter()
-
-if __name__ == "__main__":
-    test()
+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"
index c0c0b9f16524f93da3dad7705393a4d38cf66b39..236e65cd057d26f5f3138380e793de21611625d7 100644 (file)
@@ -2,22 +2,21 @@
 #
 # 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
-# 
+# FOR A PARTICULAR PURPOSE.
+#
 ##############################################################################
-"""Parse XML and compile to TALInterpreter intermediate code.
 """
-__docformat__ = 'restructuredtext'
+Parse XML and compile to TALInterpreter intermediate code.
+"""
 
-import string
 from XMLParser import XMLParser
-from TALDefs import *
+from TALDefs import XML_NS, ZOPE_I18N_NS, ZOPE_METAL_NS, ZOPE_TAL_NS
 from TALGenerator import TALGenerator
 
 class TALParser(XMLParser):
@@ -59,13 +58,15 @@ class TALParser(XMLParser):
             # attrs is a dict of {name: value}
             attrlist = attrs.items()
             attrlist.sort() # For definiteness
-        name, attrlist, taldict, metaldict = self.process_ns(name, attrlist)
+        name, attrlist, taldict, metaldict, i18ndict \
+              = self.process_ns(name, attrlist)
         attrlist = self.xmlnsattrs() + attrlist
-        self.gen.emitStartElement(name, attrlist, taldict, metaldict)
+        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:
@@ -78,10 +79,14 @@ class TALParser(XMLParser):
             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'):
+        if namens in ('metal', 'tal', 'i18n'):
             taldict['tal tag'] = namens
-        return name, fixedattrlist, taldict, metaldict
+        return name, fixedattrlist, taldict, metaldict, i18ndict
 
     def xmlnsattrs(self):
         newlist = []
@@ -90,7 +95,7 @@ class TALParser(XMLParser):
                 key = "xmlns:" + prefix
             else:
                 key = "xmlns"
-            if uri in (ZOPE_METAL_NS, ZOPE_TAL_NS):
+            if uri in (ZOPE_METAL_NS, ZOPE_TAL_NS, ZOPE_I18N_NS):
                 item = (key, uri, "xmlns")
             else:
                 item = (key, uri)
@@ -100,7 +105,7 @@ class TALParser(XMLParser):
 
     def fixname(self, name):
         if ' ' in name:
-            uri, name = string.split(name, ' ')
+            uri, name = name.split(' ')
             prefix = self.nsDict[uri]
             prefixed = name
             if prefix:
@@ -110,6 +115,8 @@ class TALParser(XMLParser):
                 ns = 'tal'
             elif uri == ZOPE_METAL_NS:
                 ns = 'metal'
+            elif uri == ZOPE_I18N_NS:
+                ns = 'i18n'
             return (prefixed, name, ns)
         return (name, name, None)
 
diff --git a/roundup/cgi/TAL/TranslationContext.py b/roundup/cgi/TAL/TranslationContext.py
new file mode 100644 (file)
index 0000000..baa08a1
--- /dev/null
@@ -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
index 00ffdc87d37748027c44ac8a0a4ff6fe95734252..8c2150589d4bf3164cee0d426f7d3e14d841fe4e 100644 (file)
@@ -2,23 +2,22 @@
 #
 # 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
-# 
+#
 ##############################################################################
-"""Generic expat-based XML parser base class.
-
-Modified for Roundup 0.5 release:
-
-- removed dependency on zLOG
-
+# Modifications for Roundup:
+# 1. commented out zLOG references
+"""
+Generic expat-based XML parser base class.
 """
-__docformat__ = 'restructuredtext'
+
+#import zLOG
 
 class XMLParser:
 
index 4f2a609e46e60e615382bfd22e16b32051e21b9c..13c443b291e4441b34d61bfdabd5bff150baf02b 100644 (file)
@@ -2,14 +2,13 @@
 #
 # 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 """
-__docformat__ = 'restructuredtext'
+""" Template Attribute Language package """
index 2bcf6a7bbd1fb3adc4cbb943df4ce1ccdffed290..b25b3eded254ddc858c351313255728023f04632 100644 (file)
@@ -1,9 +1,6 @@
-"""Shared support for scanning document type declarations in HTML and XHTML.
-"""
-__docformat__ = 'restructuredtext'
+"""Shared support for scanning document type declarations in HTML and XHTML."""
 
-import re
-import string
+import re, string
 
 _declname_match = re.compile(r'[a-zA-Z][-_.a-zA-Z0-9]*\s*').match
 _declstringlit_match = re.compile(r'(\'[^\']*\'|"[^"]*")\s*').match
@@ -23,6 +20,13 @@ class ParserBase:
         """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
@@ -31,10 +35,10 @@ class ParserBase:
         if i >= j:
             return j
         rawdata = self.rawdata
-        nlines = string.count(rawdata, "\n", i, j)
+        nlines = rawdata.count("\n", i, j)
         if nlines:
             self.lineno = self.lineno + nlines
-            pos = string.rindex(rawdata, "\n", i, j) # Should not fail
+            pos = rawdata.rindex("\n", i, j) # Should not fail
             self.offset = j-(pos+1)
         else:
             self.offset = self.offset + j-i
@@ -171,7 +175,7 @@ class ParserBase:
             return -1
         # style content model; just skip until '>'
         if '>' in rawdata[j:]:
-            return string.find(rawdata, ">", j) + 1
+            return rawdata.find(">", j) + 1
         return -1
 
     # Internal -- scan past <!ATTLIST declarations
@@ -195,10 +199,10 @@ class ParserBase:
             if c == "(":
                 # an enumerated type; look for ')'
                 if ")" in rawdata[j:]:
-                    j = string.find(rawdata, ")", j) + 1
+                    j = rawdata.find(")", j) + 1
                 else:
                     return -1
-                while rawdata[j:j+1] in string.whitespace:
+                while rawdata[j:j+1].isspace():
                     j = j + 1
                 if not rawdata[j:]:
                     # end of buffer, incomplete
@@ -299,10 +303,10 @@ class ParserBase:
         m = _declname_match(rawdata, i)
         if m:
             s = m.group()
-            name = string.strip(s)
+            name = s.strip()
             if (i + len(s)) == n:
                 return None, -1  # end of buffer
-            return string.lower(name), m.end()
+            return name.lower(), m.end()
         else:
             self.updatepos(declstartpos, i)
-            self.error("expected name token", self.getpos())
+            self.error("expected name token")
diff --git a/roundup/cgi/TAL/talgettext.py b/roundup/cgi/TAL/talgettext.py
new file mode 100644 (file)
index 0000000..12e2214
--- /dev/null
@@ -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@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@ADDRESS>\\n"
+"Language-Team: LANGUAGE <LL@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@zope.org)
+        # 14-Apr-2003 Hacked by Barry Warsaw (barry@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()
diff --git a/roundup/cgi/TranslationService.py b/roundup/cgi/TranslationService.py
new file mode 100644 (file)
index 0000000..43f503e
--- /dev/null
@@ -0,0 +1,124 @@
+# 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.6 $"[11:-2]
+__date__ = "$Date: 2008-08-18 05:04:01 $"[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):
+        if not isinstance(msgid, unicode):
+            msgid = unicode(msgid, 'utf8')
+        msgtrans=self.ugettext(msgid)
+        return msgtrans.encode(self.OUTPUT_ENCODING)
+
+    def ngettext(self, singular, plural, number):
+        if not isinstance(singular, unicode):
+            singular = unicode(singular, 'utf8')
+        if not isinstance(plural, unicode):
+            plural = unicode(plural, 'utf8')
+        msgtrans=self.ungettext(singular, plural, number)
+        return msgtrans.encode(self.OUTPUT_ENCODING)
+
+class TranslationService(TranslationServiceMixin, i18n.RoundupTranslations):
+    pass
+
+class NullTranslationService(TranslationServiceMixin,
+        i18n.RoundupNullTranslations):
+    def ugettext(self, message):
+        if self._fallback:
+            return self._fallback.ugettext(message)
+        # Sometimes the untranslatable message is a UTF-8 encoded string
+        # (thanks to PageTemplate's internals).
+        if not isinstance(message, unicode):
+            return unicode(message, 'utf8')
+        return message
+
+### 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 :
index 96a64ba31caab501ac6b39f53f09c759dd100064..d7f9fff763ee99f35af161c38b3c4956785a7770 100644 (file)
@@ -18,9 +18,9 @@ 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.3 2004-02-11 23:55:09 richard Exp $'''
+$Id: Iterator.py,v 1.4 2005-02-16 22:07:33 richard Exp $'''
 __docformat__ = 'restructuredtext'
-__version__='$Revision: 1.3 $'[11:-2]
+__version__='$Revision: 1.4 $'[11:-2]
 
 import string
 
@@ -31,13 +31,9 @@ class Iterator:
 
     nextIndex = 0
     def __init__(self, seq):
-        self.seq = seq
-        for inner in seqInner, iterInner:
-            if inner._supports(seq):
-                self._inner = inner
-                self._prep_next = inner.prep_next
-                return
-        raise TypeError, "Iterator does not support %s" % `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:
diff --git a/roundup/cgi/accept_language.py b/roundup/cgi/accept_language.py
new file mode 100755 (executable)
index 0000000..d3d7328
--- /dev/null
@@ -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@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 :
index e8f91d72b282360efca107bbeb9e368725bd4514..ac43e4c0a91fe1164b6e1379bf01c695607738db 100755 (executable)
@@ -1,11 +1,11 @@
-#$Id: actions.py,v 1.23 2004-04-05 06:13:42 richard Exp $
+#$Id: actions.py,v 1.73 2008-08-18 05:04:01 richard Exp $
 
-import re, cgi, StringIO, urllib, Cookie, time, random
+import re, cgi, StringIO, urllib, time, random, csv, codecs
 
-from roundup import hyperdb, token, date, password, rcsv, exceptions
+from roundup import hyperdb, token, date, password
 from roundup.i18n import _
-from roundup.cgi import templating
-from roundup.cgi.exceptions import Redirect, Unauthorised, SeriousError
+import roundup.exceptions
+from roundup.cgi import exceptions, templating
 from roundup.mailgw import uidFromAddress
 
 __all__ = ['Action', 'ShowAction', 'RetireAction', 'SearchAction',
@@ -27,6 +27,11 @@ class Action:
         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."""
@@ -49,36 +54,49 @@ class Action:
         if (self.permissionType and
                 not self.hasPermission(self.permissionType)):
             info = {'action': self.name, 'classname': self.classname}
-            raise Unauthorised, _('You do not have permission to '
+            raise exceptions.Unauthorised, self._(
+                'You do not have permission to '
                 '%(action)s the %(classname)s class.')%info
 
-    def hasPermission(self, permission):
+    _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,
-            self.client.classname)
+            classname=classname, itemid=itemid)
+
+    def gettext(self, msgid):
+        """Return the localized translation of msgid"""
+        return self.client.translator.gettext(msgid)
+
+    _ = gettext
 
 class ShowAction(Action):
-    def handle(self, typere=re.compile('[@:]type'),
-               numre=re.compile('[@:]number')):
+
+    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 typere.match(key):
+            if self.typere.match(key):
                 t = self.form[key].value.strip()
-            elif numre.match(key):
+            elif self.numre.match(key):
                 n = self.form[key].value.strip()
         if not t:
-            raise ValueError, 'No type specified'
+            raise ValueError, self._('No type specified')
         if not n:
-            raise SeriousError, _('No ID entered')
+            raise exceptions.SeriousError, self._('No ID entered')
         try:
             int(n)
         except ValueError:
             d = {'input': n, 'classname': t}
-            raise SeriousError, _(
+            raise exceptions.SeriousError, self._(
                 '"%(input)s" is not an ID (%(classname)s ID required)')%d
         url = '%s%s%s'%(self.base, t, n)
-        raise Redirect, url
+        raise exceptions.Redirect, url
 
 class RetireAction(Action):
     name = 'retire'
@@ -95,21 +113,27 @@ class RetireAction(Action):
         # 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, _('You may not retire the admin or anonymous user')
+            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(
-            _('%(classname)s %(itemid)s has been retired')%{
+            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, wcre=re.compile(r'[\s,]+')):
+    def handle(self):
         """Mangle some of the form variables.
 
         Set the form ":filter" variable based on the values of the filter
@@ -125,43 +149,59 @@ class SearchAction(Action):
         self.fakeFilterVars()
         queryname = self.getQueryName()
 
+        # editing existing query name?
+        old_queryname = self.getFromForm('old-queryname')
+
         # 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_href('', {})[1:]
+            url = self.getCurrentURL(req)
 
             key = self.db.query.getkey()
             if key:
                 # edit the old way, only one query per name
                 try:
-                    qid = self.db.query.lookup(queryname)
+                    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({}, {'name': queryname,
+                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 a public one created by them
-                    qids = self.db.query.filter({}, {'name': queryname,
-                        'private_for': -1, 'creator': uid})
-
-                if qids:
-                    # edit query
-                    qid = qids[0]
-                    self.db.query.set(qid, klass=self.classname, url=url)
+                    # - 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)
 
@@ -176,9 +216,10 @@ class SearchAction(Action):
 
     def fakeFilterVars(self):
         """Add a faked :filter form variable for each filtering prop."""
-        props = self.db.classes[self.classname].getprops()
+        cls = self.db.classes[self.classname]
         for key in self.form.keys():
-            if not props.has_key(key):
+            prop = cls.get_transitive_prop(key)
+            if not prop:
                 continue
             if isinstance(self.form[key], type([])):
                 # search for at least one entry which is not empty
@@ -190,7 +231,7 @@ class SearchAction(Action):
             else:
                 if not self.form[key].value:
                     continue
-                if isinstance(props[key], hyperdb.String):
+                if isinstance(prop, hyperdb.String):
                     v = self.form[key].value
                     l = token.token_split(v)
                     if len(l) > 1 or l[0] != v:
@@ -201,13 +242,29 @@ class SearchAction(Action):
 
             self.form.value.append(cgi.MiniFieldStorage('@filter', key))
 
-    FV_QUERYNAME = re.compile(r'[@:]queryname')
-    def getQueryName(self):
-        for key in self.form.keys():
-            if self.FV_QUERYNAME.match(key):
+    def getCurrentURL(self, req):
+        """Get current URL for storing as a query.
+
+        Note: We are removing the first character from the current URL,
+        because the leading '?' is not part of the query string.
+
+        Implementation note:
+        But maybe the template should be part of the stored query:
+        template = self.getFromForm('template')
+        if template:
+            return req.indexargs_url('', {'@template' : template})[1:]
+        """
+        return req.indexargs_url('', {})[1:]
+
+    def getFromForm(self, name):
+        for key in ('@' + name, ':' + name):
+            if self.form.has_key(key):
                 return self.form[key].value.strip()
         return ''
 
+    def getQueryName(self):
+        return self.getFromForm('queryname')
+
 class EditCSVAction(Action):
     name = 'edit'
     permissionType = 'Edit'
@@ -220,11 +277,6 @@ class EditCSVAction(Action):
         removed lines are retired.
 
         """
-        # get the CSV module
-        if rcsv.error:
-            self.client.error_message.append(_(rcsv.error))
-            return
-
         cl = self.db.classes[self.classname]
         idlessprops = cl.getprops(protected=0).keys()
         idlessprops.sort()
@@ -232,7 +284,7 @@ class EditCSVAction(Action):
 
         # do the edit
         rows = StringIO.StringIO(self.form['rows'].value)
-        reader = rcsv.reader(rows, rcsv.comma_separated)
+        reader = csv.reader(rows)
         found = {}
         line = 0
         for values in reader:
@@ -255,7 +307,7 @@ class EditCSVAction(Action):
             # confirm correct weight
             if len(idlessprops) != len(values):
                 self.client.error_message.append(
-                    _('Not enough values on line %(line)s')%{'line':line})
+                    self._('Not enough values on line %(line)s')%{'line':line})
                 return
 
             # extract the new values
@@ -302,48 +354,12 @@ class EditCSVAction(Action):
         # all OK
         self.db.commit()
 
-        self.client.ok_message.append(_('Items edited OK'))
-
-class _EditAction(Action):
-    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')
-
-    def editItemPermission(self, props):
-        """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 Unauthorised, _("You do not have permission to edit user roles")
-            if self.isEditingSelf():
-                return 1
-        if self.hasPermission('Edit'):
-            return 1
-        return 0
-
-    def newItemPermission(self, props):
-        """Determine whether the user has permission to create (edit) this item.
+        self.client.ok_message.append(self._('Items edited OK'))
 
-        Base behaviour is to check the user can edit this class. No additional
-        property checks are made. Additionally, new user items may be created
-        if the user has the "Web Registration" Permission.
-
-        """
-        if (self.classname == 'user' and self.hasPermission('Web Registration')
-            or self.hasPermission('Edit')):
-            return 1
-        return 0
+class EditCommon(Action):
+    '''Utility methods for editing.'''
 
-    #
-    #  Utility methods for editing
-    #
-    def _editnodes(self, all_props, all_links, newids=None):
+    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.
         '''
@@ -351,9 +367,11 @@ class _EditAction(Action):
         deps = {}
         links = {}
         for cn, nodeid, propname, vlist in all_links:
-            if not all_props.has_key((cn, nodeid)):
+            numeric_id = int (nodeid or 0)
+            if not (numeric_id > 0 or 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
@@ -385,32 +403,33 @@ class _EditAction(Action):
         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(props.keys())
-                    m.append('%s %s %s edited ok'%(cn, nodeid, info))
+            if props:
+                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:
-                    m.append('%s %s - nothing changed'%(cn, nodeid))
-            else:
-                assert props
+                    assert props
 
-                # make a new node
-                newid = self._createnode(cn, props)
-                if nodeid is None:
-                    self.nodeid = newid
-                nodeid = newid
+                    # 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('%s %s created'%(cn, 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):
@@ -439,8 +458,10 @@ class _EditAction(Action):
     def _changenode(self, cn, nodeid, props):
         """Change the node based on the contents of the form."""
         # check for permission
-        if not self.editItemPermission(props):
-            raise Unauthorised, 'You do not have permission to edit %s'%cn
+        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]
@@ -449,32 +470,88 @@ class _EditAction(Action):
     def _createnode(self, cn, props):
         """Create a node based on the contents of the form."""
         # check for permission
-        if not self.newItemPermission(props):
-            raise Unauthorised, 'You do not have permission to create %s'%cn
+        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)
 
-class EditItemAction(_EditAction):
+    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'):
-            return date.Date(self.form[':lastactivity'].value)
+            d = date.Date(self.form[':lastactivity'].value)
         elif self.form.has_key('@lastactivity'):
-            return date.Date(self.form['@lastactivity'].value)
+            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)
-        return cl.get(self.nodeid, 'activity')
+        activity = cl.get(self.nodeid, 'activity').local(0)
+        activity.second = int(activity.second)
+        return activity
 
     def detectCollision(self, user_activity, node_activity):
-        if user_activity:
-            return 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):
-        self.client.template = 'collision'
+    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.
@@ -483,18 +560,21 @@ class EditItemAction(_EditAction):
 
         """
         user_activity = self.lastUserActivity()
-        if user_activity and self.detectCollision(user_activity,
-                self.lastNodeActivity()):
-            self.handleCollision()
-            return
+        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, exceptions.Reject), message:
-            self.client.error_message.append(_('Apply Error: ') + str(message))
+        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
@@ -511,10 +591,10 @@ class EditItemAction(_EditAction):
             urllib.quote(self.template))
         if self.nodeid is None:
             req = templating.HTMLRequest(self.client)
-            url += '&' + req.indexargs_href('', {})[1:]
-        raise Redirect, url
+            url += '&' + req.indexargs_url('', {})[1:]
+        raise exceptions.Redirect, url
 
-class NewItemAction(_EditAction):
+class NewItemAction(EditCommon):
     def handle(self):
         ''' Add a new item to the database.
 
@@ -525,25 +605,26 @@ class NewItemAction(_EditAction):
         try:
             props, links = self.client.parsePropsFromForm(create=1)
         except (ValueError, KeyError), message:
-            self.client.error_message.append(_('Error: ') + str(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, exceptions.Reject), message:
+        except (ValueError, KeyError, IndexError,
+                roundup.exceptions.Reject), message:
             # these errors might just be indicative of user dumbness
-            self.client.error_message.append(_('Error: ') + str(message))
+            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 Redirect, '%s%s%s?@ok_message=%s&@template=%s'%(self.base,
-            self.classname, self.nodeid, urllib.quote(messages),
+        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):
@@ -554,21 +635,23 @@ class PassResetAction(Action):
         "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
-            otks = self.db.getOTKManager()
-            uid = otks.get(otk, 'uid')
+            uid = otks.get(otk, 'uid', default=None)
             if uid is None:
-                self.client.error_message.append("""Invalid One Time Key!
-(a Mozilla bug may cause this message to show up erroneously,
- please check your email)""")
+                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()
@@ -601,7 +684,7 @@ Your password is now: %(password)s
                 return
 
             self.client.ok_message.append(
-                    'Password reset and email sent to %s'%address)
+                self._('Password reset and email sent to %s') % address)
             return
 
         # no OTK, so now figure the user
@@ -610,19 +693,20 @@ Your password is now: %(password)s
             try:
                 uid = self.db.user.lookup(name)
             except KeyError:
-                self.client.error_message.append('Unknown username')
+                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('Unknown email address')
+                self.client.error_message.append(
+                    self._('Unknown email address'))
                 return
             name = self.db.user.get(uid, 'username')
         else:
-            self.client.error_message.append('You need to specify a username '
-                'or address')
+            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
@@ -647,33 +731,21 @@ You should then receive another email with the new password.
         if not self.client.standard_message([address], subject, body):
             return
 
-        self.client.ok_message.append('Email sent to %s'%address)
-
-class ConfRegoAction(Action):
-    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.client.ok_message.append(self._('Email sent to %s') % address)
 
+class RegoCommon(Action):
+    def finishRego(self):
         # log the new user in
-        self.client.user = self.db.user.get(self.userid, 'username')
+        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(self.client.user)
+        self.client.opendb(user)
 
-        # if we have a session, update it
-        if hasattr(self, 'session'):
-            self.client.db.sessions.set(self.session, user=self.user,
-                last_use=time.time())
-        else:
-            # new session cookie
-            self.client.set_cookie(self.user)
+        # update session data
+        self.client.session_api.set(user=user)
 
         # nice message
-        message = _('You are now registered, welcome!')
+        message = self._('You are now registered, welcome!')
         url = '%suser%s?@ok_message=%s'%(self.base, self.userid,
             urllib.quote(message))
 
@@ -685,55 +757,88 @@ class ConfRegoAction(Action):
             window.setTimeout('window.location = "%s"', 1000);
             </script>'''%(message, url, message, url)
 
-class RegisterAction(Action):
+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
+        return self.finishRego()
+
+class RegisterAction(RegoCommon, EditCommon):
     name = 'register'
-    permissionType = 'Web Registration'
+    permissionType = 'Create'
 
     def handle(self):
         """Attempt to create a new user based on the contents of the form
-        and then set the cookie.
+        and then remember it in session.
 
         Return 1 on successful login.
         """
-        props = self.client.parsePropsFromForm(create=1)[0][('user', None)]
+        # 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
-        if props.has_key('roles'):
-            raise Unauthorised, _("It is not permitted to supply roles "
-                "at registration.")
+        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
 
-        username = props['username']
-        try:
-            self.db.user.lookup(username)
-            self.client.error_message.append(_('Error: A user with the '
-                'username "%(username)s" already exists')%props)
-            return
-        except KeyError:
-            pass
+            # 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 = props.get(propname, None)
+            value = user_props.get(propname, None)
             if value is None:
                 pass
             elif isinstance(proptype, hyperdb.Date):
-                props[propname] = str(value)
+                user_props[propname] = str(value)
             elif isinstance(proptype, hyperdb.Interval):
-                props[propname] = str(value)
+                user_props[propname] = str(value)
             elif isinstance(proptype, hyperdb.Password):
-                props[propname] = str(value)
+                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, **props)
+        otks.set(otk, **user_props)
 
         # send the email
         tracker_name = self.db.config.TRACKER_NAME
         tracker_email = self.db.config.TRACKER_EMAIL
-        subject = 'Complete your registration to %s -- key %s'%(tracker_name,
+        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
+            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
@@ -743,32 +848,42 @@ reply's additional "Re:" is ok),
 
 %(url)s?@action=confrego&otk=%(otk)s
 
-""" % {'name': props['username'], 'tracker': tracker_name, 'url': self.base,
-        'otk': otk, 'tracker_email': tracker_email}
-        if not self.client.standard_message([props['address']], subject,
-                body, tracker_email):
+""" % {'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 Redirect, '%suser?@template=rego_progress'%self.base
+        raise exceptions.Redirect, '%suser?@template=rego_progress'%self.base
 
 class LogoutAction(Action):
     def handle(self):
-        """Make us really anonymous - nuke the cookie too."""
+        """Make us really anonymous - nuke the session 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)
+        self.client.session_api.destroy()
 
         # Let the user know what's going on
-        self.client.ok_message.append(_('You are logged out'))
+        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):
@@ -779,7 +894,7 @@ class LoginAction(Action):
         """
         # we need the username at a minimum
         if not self.form.has_key('__login_name'):
-            self.client.error_message.append(_('Username required'))
+            self.client.error_message.append(self._('Username required'))
             return
 
         # get the login info
@@ -789,38 +904,45 @@ class LoginAction(Action):
         else:
             password = ''
 
-        # make sure the user exists
         try:
-            self.client.userid = self.db.user.lookup(self.client.user)
-        except KeyError:
-            name = self.client.user
-            self.client.error_message.append(_('No such user "%(name)s"')%locals())
+            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)
+
+        # save user in session
+        self.client.session_api.set(user=self.client.user)
+        if self.form.has_key('remember'):
+            self.client.session_api.update(set_cookie=True, expire=24*3600*365)
+
+        # 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):
-            self.client.make_user_anonymous()
-            self.client.error_message.append(_('Incorrect password'))
-            return
+            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"):
-            self.client.make_user_anonymous()
-            self.client.error_message.append(_("You do not have permission to login"))
-            return
-
-        # now we're OK, re-open the database for real, using the user
-        self.client.opendb(self.client.user)
-
-        # set the session cookie
-        self.client.set_cookie(self.client.user)
+            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(self.client.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:
@@ -849,17 +971,28 @@ class ExportCSVAction(Action):
             matches = None
 
         h = self.client.additional_headers
-        h['Content-Type'] = 'text/csv'
+        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()
-        writer = rcsv.writer(self.client.request.wfile)
-        writer.writerow(columns)
+
+        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)
+        self.client._socket_op(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])
+            self.client._socket_op(writer.writerow, [str(klass.get(itemid, col)) for col in columns])
 
         return '\n'
 
-# vim: set filetype=python ts=4 sw=4 et si
+# vim: set filetype=python sts=4 sw=4 et si :
diff --git a/roundup/cgi/apache.py b/roundup/cgi/apache.py
new file mode 100644 (file)
index 0000000..846b921
--- /dev/null
@@ -0,0 +1,114 @@
+# 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.6 $"[11:-2]
+__date__ = "$Date: 2006-11-09 00:36:21 $"[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 start_response(self, headers, response):
+        self.send_response(response)
+        for key, value in headers:
+            self.send_header(key, value)
+        self.end_headers()
+
+    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 :
index a78188a4f11d86c514c7aed90e06345708bdd96a..50ea47576b0328ee308a48daf261f8e44724828c 100644 (file)
@@ -1,7 +1,7 @@
 #
 # This module was written by Ka-Ping Yee, <ping@lfw.org>.
-# 
-# $Id: cgitb.py,v 1.10 2004-02-11 23:55:09 richard Exp $
+#
+# $Id: cgitb.py,v 1.12 2004-07-13 10:18:00 a1s Exp $
 
 """Extended CGI traceback handler by Ka-Ping Yee, <ping@lfw.org>.
 """
@@ -10,7 +10,25 @@ __docformat__ = 'restructuredtext'
 import sys, os, types, string, keyword, linecache, tokenize, inspect, cgi
 import pydoc, traceback
 
-from roundup.i18n import _
+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">' +
@@ -24,12 +42,14 @@ def niceDict(indent, dict):
             cgi.escape(repr(v))))
     return '\n'.join(l)
 
-def pt_html(context=5):
+def pt_html(context=5, i18n=None):
+    _ = get_translator(i18n)
     esc = cgi.escape
-    l = ['<h1>Templating Error</h1>',
-         '<p><b>%s</b>: %s</p>'%(esc(str(sys.exc_type)),
-            esc(str(sys.exc_value))),
-         '<p class="help">Debugging information follows</p>',
+    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)
@@ -41,50 +61,60 @@ def pt_html(context=5):
             if isinstance(ti, TraversalError):
                 s = []
                 for name, info in ti.path:
-                    s.append('<li>"%s" (%s)</li>'%(name, esc(repr(info))))
+                    s.append(_('<li>"%(name)s" (%(info)s)</li>')
+                        % {'name': name, 'info': esc(repr(info))})
                 s = '\n'.join(s)
-                l.append('<li>Looking for "%s", current path:<ol>%s</ol></li>'%(
-                    ti.name, 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)))
+                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)
+                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 %r expression on line %d
+                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>
- %s
- %s
+ %(globals)s
+ %(locals)s
 </table></li>
-'''%(info, context.position[0], niceDict('    ', context.global_vars),
-     niceDict('    ', context.local_vars)))
+''') % {
+    '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">Full traceback:</th></tr>
+ <tr><th class="header" align="left">%s</th></tr>
  <tr><td><pre>%s</pre></td></tr>
-</table>'''%cgi.escape(''.join(traceback.format_exception(sys.exc_type,
-        sys.exc_value, sys.exc_traceback))))
+</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):
+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>%s</strong>: %s</font>'%(etype, evalue),
+        _('<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. '
@@ -96,8 +126,8 @@ def html(context=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;'''
+            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))
@@ -105,7 +135,7 @@ def html(context=5):
         if func == '?':
             call = ''
         else:
-            call = 'in <strong>%s</strong>' % func + inspect.formatargvalues(
+            call = _('in <strong>%s</strong>') % func + inspect.formatargvalues(
                     args, varargs, varkw, locals,
                     formatvalue=lambda value: '=' + pydoc.html.repr(value))
 
@@ -186,4 +216,4 @@ def handler():
     print breaker()
     print html()
 
-# vim: set filetype=python ts=4 sw=4 et si
+# vim: set filetype=python ts=4 sw=4 et si :
index b68d3afec33e2bd4a96c9ddf87d2ae57b1867a19..a3ea172978451d86b499fc8db772c7a61ad0ea56 100644 (file)
@@ -1,19 +1,22 @@
-# $Id: client.py,v 1.170 2004-04-05 06:13:42 richard Exp $
+# $Id: client.py,v 1.239 2008-08-18 05:04:02 richard Exp $
 
 """WWW request handler (also used in the stand-alone server).
 """
 __docformat__ = 'restructuredtext'
 
-import os, os.path, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib
-import binascii, Cookie, time, random, stat, rfc822
+import base64, binascii, cgi, codecs, mimetypes, os
+import random, re, rfc822, stat, time, urllib, urlparse
+import Cookie, socket, errno
+from Cookie import CookieError, BaseCookie, SimpleCookie
 
 from roundup import roundupdb, date, hyperdb, password
-from roundup.i18n import _
-from roundup.cgi import templating, cgitb
+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
@@ -21,13 +24,12 @@ def initialiseSecurity(security):
     This function is directly invoked by security.Security.__init__()
     as a part of the Security object instantiation.
     '''
-    security.addPermission(name="Web Registration",
-        description="User may register through the web")
     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)
@@ -45,6 +47,153 @@ def clean_message_callback(match, ok={'a':1,'i':1,'b':1,'br':1}):
         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 LiberalCookie(SimpleCookie):
+    ''' Python's SimpleCookie throws an exception if the cookie uses invalid
+        syntax.  Other applications on the same server may have done precisely
+        this, preventing roundup from working through no fault of roundup.
+        Numerous other python apps have run into the same problem:
+
+        trac: http://trac.edgewall.org/ticket/2256
+        mailman: http://bugs.python.org/issue472646
+
+        This particular implementation comes from trac's solution to the
+        problem. Unfortunately it requires some hackery in SimpleCookie's
+        internals to provide a more liberal __set method.
+    '''
+    def load(self, rawdata, ignore_parse_errors=True):
+        if ignore_parse_errors:
+            self.bad_cookies = []
+            self._BaseCookie__set = self._loose_set
+        SimpleCookie.load(self, rawdata)
+        if ignore_parse_errors:
+            self._BaseCookie__set = self._strict_set
+            for key in self.bad_cookies:
+                del self[key]
+
+    _strict_set = BaseCookie._BaseCookie__set
+
+    def _loose_set(self, key, real_value, coded_value):
+        try:
+            self._strict_set(key, real_value, coded_value)
+        except CookieError:
+            self.bad_cookies.append(key)
+            dict.__setitem__(self, key, None)
+
+
+class Session:
+    '''
+    Needs DB to be already opened by client
+
+    Session attributes at instantiation:
+
+    - "client" - reference to client for add_cookie function
+    - "session_db" - session DB manager
+    - "cookie_name" - name of the cookie with session id
+    - "_sid" - session id for current user
+    - "_data" - session data cache
+
+    session = Session(client)
+    session.set(name=value)
+    value = session.get(name)
+
+    session.destroy()  # delete current session
+    session.clean_up() # clean up session table
+
+    session.update(set_cookie=True, expire=3600*24*365)
+                       # refresh session expiration time, setting persistent
+                       # cookie if needed to last for 'expire' seconds
+
+    '''
+
+    def __init__(self, client):
+        self._data = {}
+        self._sid  = None
+
+        self.client = client
+        self.session_db = client.db.getSessionManager()
+
+        # parse cookies for session id
+        self.cookie_name = 'roundup_session_%s' % \
+            re.sub('[^a-zA-Z]', '', client.instance.config.TRACKER_NAME)
+        cookies = LiberalCookie(client.env.get('HTTP_COOKIE', ''))
+        if self.cookie_name in cookies:
+            if not self.session_db.exists(cookies[self.cookie_name].value):
+                self._sid = None
+                # remove old cookie
+                self.client.add_cookie(self.cookie_name, None)
+            else:
+                self._sid = cookies[self.cookie_name].value
+                self._data = self.session_db.getall(self._sid)
+
+    def _gen_sid(self):
+        ''' generate a unique session key '''
+        while 1:
+            s = '%s%s'%(time.time(), random.random())
+            s = binascii.b2a_base64(s).strip()
+            if not self.session_db.exists(s):
+                break
+
+        # clean up the base64
+        if s[-1] == '=':
+            if s[-2] == '=':
+                s = s[:-2]
+            else:
+                s = s[:-1]
+        return s
+
+    def clean_up(self):
+        '''Remove expired sessions'''
+        self.session_db.clean()
+
+    def destroy(self):
+        self.client.add_cookie(self.cookie_name, None)
+        self._data = {}
+        self.session_db.destroy(self._sid)
+        self.client.db.commit()
+
+    def get(self, name, default=None):
+        return self._data.get(name, default)
+
+    def set(self, **kwargs):
+        self._data.update(kwargs)
+        if not self._sid:
+            self._sid = self._gen_sid()
+            self.session_db.set(self._sid, **self._data)
+            # add session cookie
+            self.update(set_cookie=True)
+
+            # XXX added when patching 1.4.4 for backward compatibility
+            # XXX remove
+            self.client.session = self._sid
+        else:
+            self.session_db.set(self._sid, **self._data)
+            self.client.db.commit()
+
+    def update(self, set_cookie=False, expire=None):
+        ''' update timestamp in db to avoid expiration
+
+            if 'set_cookie' is True, set cookie with 'expire' seconds lifetime
+            if 'expire' is None - session will be closed with the browser
+             
+            XXX the session can be purged within a week even if a cookie
+                lifetime is longer
+        '''
+        self.session_db.updateTimestamp(self._sid)
+        self.client.db.commit()
+
+        if set_cookie:
+            self.client.add_cookie(self.cookie_name, self._sid, expire=expire)
+
+
+
 class Client:
     '''Instantiate to handle one CGI request.
 
@@ -59,12 +208,15 @@ class Client:
     - "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:
 
+    - "db" 
     - "error_message" holds a list of error messages
     - "ok_message" holds a list of OK messages
-    - "session" is the current user session id
+    - "session" is deprecated in favor of session_api (XXX remove)
+    - "session_api" is the interface to store data in session
     - "user" is the current user's name
     - "userid" is the current user's id
     - "template" is the current :template context
@@ -72,12 +224,12 @@ class Client:
     - "nodeid" is the current context item id
 
     User Identification:
-     If the user has no login cookie, then they are anonymous and are logged
+     Users that are absent in session data 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.
+     Every user is assigned a session. "session_api" is the interface to work
+     with session data.
 
     Special form variables:
      Note that in various places throughout this code, special form
@@ -85,6 +237,11 @@ class Client:
      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
     #
@@ -96,11 +253,32 @@ class Client:
     # columns, sort, sortdir, filter, group, groupdir, search_text,
     # pagesize, startwith
 
-    def __init__(self, instance, request, env, form=None):
-        hyperdb.traceMark()
+    # list of network error codes that shouldn't be reported to tracker admin
+    # (error descriptions from FreeBSD intro(2))
+    IGNORE_NET_ERRORS = (
+        # A write on a pipe, socket or FIFO for which there is
+        # no process to read the data.
+        errno.EPIPE,
+        # A connection was forcibly closed by a peer.
+        # This normally results from a loss of the connection
+        # on the remote socket due to a timeout or a reboot.
+        errno.ECONNRESET,
+        # Software caused connection abort.  A connection abort
+        # was caused internal to your host machine.
+        errno.ECONNABORTED,
+        # A connect or send request failed because the connected party
+        # did not properly respond after a period of time.
+        errno.ETIMEDOUT,
+    )
+
+    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
@@ -116,8 +294,9 @@ class Client:
         # 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._cookies = {}
 
         # see if we need to re-parse the environment for the form (eg Zope)
         if form is None:
@@ -140,6 +319,37 @@ class Client:
         self.additional_headers = {}
         self.response_code = 200
 
+        # default character set
+        self.charset = self.STORAGE_CHARSET
+
+        # parse cookies (used for charset lookups)
+        # use our own LiberalCookie to handle bad apps on the same
+        # server that have set cookies that are out of spec
+        self.cookie = LiberalCookie(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.
@@ -155,13 +365,15 @@ class Client:
 
         The most common requests are handled like so:
 
-        1. figure out who we are, defaulting to the "anonymous" user
+        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
-        2. figure out what the request is for - the context
+        3. figure out what the request is for - the context
            see determine_context
-        3. handle any requested action (item edit, search, ...)
+        4. handle any requested action (item edit, search, ...)
            see handle_action
-        4. render a template, resulting in HTML output
+        5. render a template, resulting in HTML output
 
         In some situations, exceptions occur:
 
@@ -183,21 +395,21 @@ class Client:
         self.ok_message = []
         self.error_message = []
         try:
-            # figure out the context and desired content template
-            # do this first so we don't authenticate for static files
-            # Note: this method opens the database as "admin" in order to
-            # perform context checks
-            self.determine_context()
+            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)
+                self.write_html(html)
                 return
 
             # now render the page
@@ -206,105 +418,240 @@ class Client:
 # Pragma: no-cache makes Mozilla and its ilk double-load all pages!!
 #            self.additional_headers['Pragma'] = 'no-cache'
 
-            # expire this page 5 seconds from now
-            date = rfc822.formatdate(time.time() + 5)
-            self.additional_headers['Expires'] = date
+            # 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(self.renderContext())
+            try:
+                self.write_html(self.renderContext())
+            except IOError:
+                # IOErrors here are due to the client disconnecting before
+                # recieving the reply.
+                pass
+
         except SeriousError, message:
-            self.write(str(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.additional_headers['Location'] = str(url)
                 self.response_code = 302
-            self.write('Redirecting to <a href="%s">%s</a>'%(url, url))
+            self.write_html('Redirecting to <a href="%s">%s</a>'%(url, url))
         except SendFile, designator:
-            self.serve_file(designator)
+            try:
+                self.serve_file(designator)
+            except NotModified:
+                # send the 304 response
+                self.response_code = 304
+                self.header()
         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()
+                self.response_code = 304
+                self.header()
         except Unauthorised, message:
             # users may always see the front page
             self.classname = self.nodeid = None
             self.template = ''
             self.error_message.append(message)
-            self.write(self.renderContext())
-        except NotFound:
-            # pass through
-            raise
+            self.write_html(self.renderContext())
+        except NotFound, e:
+            self.response_code = 404
+            self.template = '404'
+            try:
+                cl = self.db.getclass(self.classname)
+                self.write_html(self.renderContext())
+            except KeyError:
+                # we can't map the URL to a class we know about
+                # reraise the NotFound and let roundup_server
+                # handle it
+                raise NotFound, e
         except FormError, e:
-            self.error_message.append(_('Form Error: ') + str(e))
-            self.write(self.renderContext())
+            self.error_message.append(self._('Form Error: ') + str(e))
+            self.write_html(self.renderContext())
         except:
-            # everything else
-            self.write(cgitb.html())
+            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.
+        """Deprecated
+           XXX remove
+        """
+        self.clean_up()
 
-        Do it only once an hour.
+    def clean_up(self):
+        """Remove expired sessions and One Time Keys.
 
-        Note: also cleans One Time Keys, and other "session" based stuff.
+           Do it only once an hour.
         """
-        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()
+
+        # XXX: hack - use OTK table to store last_clean time information
+        #      'last_clean' string is used instead of otk key
+        last_clean = self.db.getOTKManager().get('last_clean', 'last_use', 0)
         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()
+        self.session_api.clean_up()
+        self.db.getOTKManager().clean()
+        self.db.getOTKManager().set('last_clean', last_use=now)
+        self.db.commit(fail_ok=True)
 
-    def determine_user(self):
-        ''' Determine who the user is
-        '''
-        # determine the uid to use
-        self.opendb('admin')
+    def determine_charset(self):
+        """Look for client charset in the form parameters or browser cookie.
 
-        # make sure we have the session Class
-        self.clean_sessions()
-        sessions = self.db.getSessionManager()
+        If no charset requested by client, use storage charset (utf-8).
 
-        # first up, try the REMOTE_USER var (from HTTP Basic Auth handled
-        # by a front-end HTTP server)
-        try:
-            user = os.getenv('REMOTE_USER')
-        except KeyError:
-            pass
+        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 = ""
 
-        # look up the user session cookie (may override the REMOTE_USER)
-        cookie = Cookie.SimpleCookie(self.env.get('HTTP_COOKIE', ''))
-        user = 'anonymous'
-        if (cookie.has_key(self.cookie_name) and
-                cookie[self.cookie_name].value != 'deleted'):
+        self.language = language
+        if language:
+            self.setTranslator(TranslationService.get_translation(
+                    language,
+                    tracker_home=self.instance.config["TRACKER_HOME"]))
 
-            # get the session key from the cookie
-            self.session = cookie[self.cookie_name].value
-            # get the user from the session
-            try:
-                # update the lifetime datestamp
-                sessions.updateTimestamp(self.session)
-                user = sessions.get(self.session, 'user')
-            except KeyError:
-                # not valid, ignore id
-                pass
+    def determine_user(self):
+        """Determine who the user is"""
+        self.opendb('admin')
+
+        # get session data from db
+        # XXX: rename
+        self.session_api = Session(self)
+
+        # take the opportunity to cleanup expired sessions and otks
+        self.clean_up()
+
+        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 lookup
+        if not user:
+            user = self.session_api.get('user')
+            if user:
+                # update session lifetime datestamp
+                self.session_api.update()
+
+        # if no user name set by http authorization or session lookup
+        # the user is anonymous
+        if not user:
+            user = 'anonymous'
 
-        # sanity check on the user still being valid, getting the userid
-        # at the same time
+        # 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):
@@ -313,13 +660,38 @@ class Client:
         # 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 determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')):
+    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
@@ -397,15 +769,16 @@ class Client:
                 # send the file identified by the designator in path[0]
                 raise SendFile, path[0]
 
-        # we need the db for further context stuff - open it as admin
-        self.opendb('admin')
-
         # see if we got a designator
         m = dre.match(self.classname)
         if m:
             self.classname = m.group(1)
             self.nodeid = m.group(2)
-            if not self.db.getclass(self.classname).hasnode(self.nodeid):
+            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'
@@ -431,7 +804,6 @@ class Client:
             raise NotFound, str(designator)
         classname, nodeid = m.group(1), m.group(2)
 
-        self.opendb('admin')
         klass = self.db.getclass(classname)
 
         # make sure we have the appropriate properties
@@ -441,6 +813,12 @@ class Client:
         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()
@@ -450,7 +828,19 @@ class Client:
     def serve_static_file(self, file):
         ''' Serve up the file named from the templates dir
         '''
-        filename = os.path.join(self.instance.config.TEMPLATES, file)
+        # 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]
@@ -473,14 +863,20 @@ class Client:
 
         self._serve_file(lmt, mime_type, content)
 
-    def _serve_file(self, last_modified, mime_type, content):
+    def _serve_file(self, lmt, mime_type, content):
         ''' guts of serve_file() and serve_static_file()
         '''
+        # spit out headers
+        self.additional_headers['Content-Type'] = mime_type
+        self.additional_headers['Content-Length'] = str(len(content))
+        self.additional_headers['Last-Modified'] = rfc822.formatdate(lmt)
+
         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'):
+        # XXX see which interfaces set this
+        #if hasattr(self.request, 'headers'):
+            #ims = self.request.headers.getheader('if-modified-since')
+        if 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:
@@ -489,11 +885,6 @@ class Client:
             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(last_modified)
-        self.additional_headers['Last-Modifed'] = lmt
         self.write(content)
 
     def renderContext(self):
@@ -501,8 +892,7 @@ class Client:
         '''
         name = self.classname
         extension = self.template
-        pt = templating.Templates(self.instance.config.TEMPLATES).get(name,
-            extension)
+        pt = self.instance.templates.get(name, extension)
 
         # catch errors so we can handle PT rendering errors more nicely
         args = {
@@ -513,6 +903,23 @@ class Client:
             # 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
@@ -520,7 +927,7 @@ class Client:
             raise Unauthorised, str(message)
         except:
             # everything else
-            return cgitb.pt_html()
+            return cgitb.pt_html(i18n=self.translator)
 
     # these are the actions that are available
     actions = (
@@ -546,6 +953,9 @@ class Client:
 
             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()
@@ -553,13 +963,9 @@ class Client:
             action = self.form['@action'].value.lower()
         else:
             return None
+
         try:
-            # get the action, validate it
-            for name, action_klass in self.actions:
-                if name == action:
-                    break
-            else:
-                raise ValueError, 'No such action "%s"'%action
+            action_klass = self.get_action_class(action)
 
             # call the mapped action
             if isinstance(action_klass, type('')):
@@ -568,13 +974,68 @@ class Client:
             else:
                 return action_klass(self).execute()
 
-        except ValueError, err:
+        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 _socket_op(self, call, *args, **kwargs):
+        """Execute socket-related operation, catch common network errors
+
+        Parameters:
+            call: a callable to execute
+            args, kwargs: call arguments
+
+        """
+        try:
+            call(*args, **kwargs)
+        except socket.error, err:
+            err_errno = getattr (err, 'errno', None)
+            if err_errno is None:
+                try:
+                    err_errno = err[0]
+                except TypeError:
+                    pass
+            if err_errno not in self.IGNORE_NET_ERRORS:
+                raise
+
     def write(self, content):
         if not self.headers_done:
             self.header()
-        self.request.wfile.write(content)
+        if self.env['REQUEST_METHOD'] != 'HEAD':
+            self._socket_op(self.request.wfile.write, content)
+
+    def write_html(self, content):
+        if not self.headers_done:
+            # at this point, we are sure about Content-Type
+            if not self.additional_headers.has_key('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._socket_op(self.request.wfile.write, content)
 
     def setHeader(self, header, value):
         '''Override a header to be returned to the user's browser.
@@ -585,50 +1046,65 @@ class Client:
         '''Put up the appropriate header.
         '''
         if headers is None:
-            headers = {'Content-Type':'text/html'}
+            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 not headers.has_key('Content-Type'):
-            headers['Content-Type'] = 'text/html'
-        self.request.send_response(response)
-        for entry in headers.items():
-            self.request.send_header(*entry)
-        self.request.end_headers()
+        if headers.get('Content-Type', 'text/html') == 'text/html':
+            headers['Content-Type'] = 'text/html; charset=utf-8'
+
+        headers = headers.items()
+
+        for ((path, name), (value, expire)) in self._cookies.items():
+            cookie = "%s=%s; Path=%s;"%(name, value, path)
+            if expire is not None:
+                cookie += " expires=%s;"%Cookie._getdate(expire)
+            headers.append(('Set-Cookie', cookie))
+
+        self._socket_op(self.request.start_response, headers, response)
+
         self.headers_done = 1
         if self.debug:
             self.headers_sent = headers
 
-    def set_cookie(self, user):
-        """Set up a session cookie for the user.
+    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)
 
-        Also store away the user's login info against the session.
         """
-        # TODO generate a much, much stronger session key ;)
-        self.session = binascii.b2a_base64(repr(random.random())).strip()
-
-        # clean up the base64
-        if self.session[-1] == '=':
-            if self.session[-2] == '=':
-                self.session = self.session[:-2]
-            else:
-                self.session = self.session[:-1]
+        if path is None:
+            path = self.cookie_path
+        if not value:
+            expire = -1
+        self._cookies[(path, name)] = (value, expire)
 
-        # insert the session in the sessiondb
-        sessions = self.db.getSessionManager()
-        sessions.set(self.session, user=user)
-        self.db.commit()
+    def set_cookie(self, user, expire=None):
+        """Deprecated. Use session_api calls directly
 
-        # expire us in a long, long time
-        expire = Cookie._getdate(86400*365)
+        XXX remove
+        """
 
-        # generate the cookie path - make sure it has a trailing '/'
-        self.additional_headers['Set-Cookie'] = \
-          '%s=%s; expires=%s; Path=%s;'%(self.cookie_name, self.session,
-            expire, self.cookie_path)
+        # insert the session in the session db
+        self.session_api.set(user=user)
+        # refresh session cookie
+        self.session_api.update(set_cookie=True, expire=expire)
 
     def make_user_anonymous(self):
         ''' Make us anonymous
@@ -639,22 +1115,24 @@ class Client:
         self.userid = self.db.user.lookup('anonymous')
         self.user = 'anonymous'
 
-    def opendb(self, user):
-        ''' Open the database.
-        '''
-        # open the db if the user has changed
-        if not hasattr(self, 'db') or user != self.db.journaltag:
-            if hasattr(self, 'db'):
-                self.db.close()
-            self.db = self.instance.open(user)
-
     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)
-            return 1
         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 :
index 1d24fc1b3c56d631c290c5977c4fb4d343b08124..b7f33cbd8108da756394d79c9212b284867532cf 100755 (executable)
@@ -1,4 +1,4 @@
-#$Id: exceptions.py,v 1.4 2004-03-26 00:44:11 richard Exp $
+#$Id: exceptions.py,v 1.6 2004-11-18 14:10:27 a1s Exp $
 '''Exceptions for use in Roundup's web interface.
 '''
 
@@ -9,6 +9,9 @@ import cgi
 class HTTPException(Exception):
     pass
 
+class LoginError(HTTPException):
+    pass
+
 class Unauthorised(HTTPException):
     pass
 
@@ -49,12 +52,11 @@ class SeriousError(Exception):
     def __str__(self):
         return '''
 <html><head><title>Roundup issue tracker: An error has occurred</title>
- <meta http-equiv="Content-Type" content="text/html; charset=utf-8;">
- <link rel="stylesheet" type="text/css" href="_file/style.css">
+ <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 ts=4 sw=4 et si
+# vim: set filetype=python sts=4 sw=4 et si :
index 3db747a20e0d4ea60d4e45d4af7dfe9b74a7b3cd..260340e9fe5db20e7051a661df9fd1d96bdce55a 100755 (executable)
@@ -1,8 +1,8 @@
 import re, mimetypes
 
 from roundup import hyperdb, date, password
+from roundup.cgi import templating
 from roundup.cgi.exceptions import FormError
-from roundup.i18n import _
 
 class FormParser:
     # edit form variable handling (see unit tests)
@@ -26,14 +26,21 @@ class FormParser:
           )
          )
         )$'''
-    
+
     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:
@@ -79,7 +86,7 @@ class FormParser:
             @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.  
+                submitted for the edit operation to succeed.
 
                 When the <designator> is missing, the properties are
                 for the current context item.  When <designator> is
@@ -112,11 +119,11 @@ class FormParser:
 
                 For a Link('klass') property, the form value is a
                 single key for 'klass', where the key field is
-                specified in dbinit.py.  
+                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.  
+                key field is specified in dbinit.py.
 
                 Note that for simple-form-variables specifiying Link
                 and Multilink properties, the linked-to class must
@@ -168,7 +175,7 @@ class FormParser:
             actual content, otherwise we remove them from all_props before
             returning.
 
-            The return from this method is a dict of 
+            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
@@ -273,22 +280,46 @@ class FormParser:
                 for entry in self.extractFormList(form[key]):
                     m = self.FV_DESIGNATOR.match(entry)
                     if not m:
-                        raise FormError, \
-                            'link "%s" value "%s" not a designator'%(key, entry)
+                        raise FormError, self._('link "%(key)s" '
+                            'value "%(entry)s" not a designator') % locals()
                     value.append((m.group(1), m.group(2)))
 
+                    # get details of linked class
+                    lcn = m.group(1)
+                    lcl = self.db.classes[lcn]
+                    lnodeid = m.group(2)
+                    if not all_propdef.has_key(lcn):
+                        all_propdef[lcn] = lcl.getprops()
+                    if not all_props.has_key((lcn, lnodeid)):
+                        all_props[(lcn, lnodeid)] = {}
+                    if not got_props.has_key((lcn, lnodeid)):
+                        got_props[(lcn, lnodeid)] = {}
+
                 # make sure the link property is valid
                 if (not isinstance(propdef[propname], hyperdb.Multilink) and
                         not isinstance(propdef[propname], hyperdb.Link)):
-                    raise FormError, '%s %s is not a link or '\
-                        'multilink property'%(cn, propname)
+                    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])
+                for entry in self.extractFormList(form[key]):
+                    m = self.FV_SPECIAL.match(entry)
+                    if not m:
+                        raise FormError, self._('The form action claims to '
+                            'require property "%(property)s" '
+                            'which doesn\'t exist') % {
+                            'property':propname}
+                    if m.group('classname'):
+                        this = (m.group('classname'), m.group('id'))
+                        entry = m.group('propname')
+                    if not all_required.has_key(this):
+                        all_required[this] = []
+                    all_required[this].append(entry)
                 continue
 
             # see if we're performing a special multilink action
@@ -301,16 +332,17 @@ class FormParser:
             # does the property exist?
             if not propdef.has_key(propname):
                 if mlaction != 'set':
-                    raise FormError, 'You have submitted a %s action for'\
-                        ' the property "%s" which doesn\'t exist'%(mlaction,
-                        propname)
+                    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.
+            # 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
@@ -319,8 +351,8 @@ class FormParser:
             else:
                 # multiple values are not OK
                 if isinstance(value, type([])):
-                    raise FormError, 'You have submitted more than one value'\
-                        ' for the %s property'%propname
+                    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
@@ -342,14 +374,14 @@ class FormParser:
                         confirm = form[key]
                         break
                 else:
-                    raise FormError, 'Password and confirmation text do '\
-                        'not match'
+                    raise FormError, self._('Password and confirmation text '
+                        'do not match')
                 if isinstance(confirm, type([])):
-                    raise FormError, 'You have submitted more than one value'\
-                        ' for the %s property'%propname
+                    raise FormError, self._('You have submitted more than one '
+                        'value for the %s property') % propname
                 if value != confirm.value:
-                    raise FormError, 'Password and confirmation text do '\
-                        'not match'
+                    raise FormError, self._('Password and confirmation text '
+                        'do not match')
                 try:
                     value = password.Password(value)
                 except hyperdb.HyperdbValueError, msg:
@@ -383,8 +415,9 @@ class FormParser:
                             try:
                                 existing.remove(entry)
                             except ValueError:
-                                raise FormError, _('property "%(propname)s": '
-                                    '"%(value)s" not currently in list')%{
+                                raise FormError, self._('property '
+                                    '"%(propname)s": "%(value)s" '
+                                    'not currently in list') % {
                                     'propname': propname, 'value': entry}
                     else:
                         # add - easy, just don't dupe
@@ -432,7 +465,10 @@ class FormParser:
                     raise FormError, msg
 
             # register that we got this property
-            if value:
+            if isinstance(proptype, hyperdb.Multilink):
+                if value != []:
+                    got_props[this][propname] = 1
+            elif value is not None:
                 got_props[this][propname] = 1
 
             # get the old value
@@ -453,15 +489,18 @@ class FormParser:
 
                 # "missing" existing values may not be None
                 if not existing:
-                    if isinstance(proptype, hyperdb.String) and not existing:
+                    if isinstance(proptype, hyperdb.String):
                         # some backends store "missing" Strings as empty strings
-                        existing = None
-                    elif isinstance(proptype, hyperdb.Number) and not existing:
+                        if existing == self.db.BACKEND_MISSING_STRING:
+                            existing = None
+                    elif isinstance(proptype, hyperdb.Number):
                         # some backends store "missing" Numbers as 0 :(
-                        existing = 0
-                    elif isinstance(proptype, hyperdb.Boolean) and not existing:
+                        if existing == self.db.BACKEND_MISSING_NUMBER:
+                            existing = None
+                    elif isinstance(proptype, hyperdb.Boolean):
                         # likewise Booleans
-                        existing = 0
+                        if existing == self.db.BACKEND_MISSING_BOOLEAN:
+                            existing = None
 
                 # if changed, set it
                 if value != existing:
@@ -490,17 +529,32 @@ class FormParser:
                 if got.has_key(entry):
                     required.remove(entry)
 
+            # If a user doesn't have edit permission for a given property,
+            # but the property is already set in the database, we don't
+            # require a value.
+            if not (create or nodeid is None):
+                for entry in required[:]:
+                    if not self.db.security.hasPermission('Edit',
+                                                          self.client.userid,
+                                                          self.classname,
+                                                          entry):
+                        cl = self.db.classes[self.classname]
+                        if cl.get(nodeid, entry) is not None:
+                            required.remove(entry)
+
             # any required values not present?
             if not required:
                 continue
 
             # tell the user to entry the values required
-            if len(required) > 1:
-                p = 'properties'
-            else:
-                p = 'property'
-            s.append('Required %s %s %s not supplied'%(thing[0], p,
-                ', '.join(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)
 
@@ -509,12 +563,15 @@ class FormParser:
         # 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 isinstance(self.db.classes[cn], hyperdb.FileClass):
-                if id == '-1':
+            if id and id.startswith('-') 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 and id.startswith('-'):
                     if not props.get('content', ''):
                         del all_props[(cn, id)]
                 elif props.has_key('content') and not props['content']:
-                    raise FormError, _('File is empty')
+                    raise FormError, self._('File is empty')
         return all_props, all_links
 
     def extractFormList(self, value):
@@ -538,3 +595,5 @@ class FormParser:
 
         # filter out the empty bits
         return filter(None, value)
+
+# vim: set et sts=4 sw=4 :
index bf3d901414977c5d0ca01cc0f228f16d90df1d7a..106df96e2d90a6b3143eff92889e1300220415c0 100644 (file)
@@ -1,22 +1,30 @@
+from __future__ import nested_scopes
+
 """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. 
+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
-- HTMLProperty should have an isset() method
-'''
+- 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
+import sys, cgi, urllib, os, re, os.path, time, errno, mimetypes, csv
+import calendar, textwrap
 
-from roundup import hyperdb, date, rcsv
+from roundup import hyperdb, date, support
+from roundup import i18n
 from roundup.i18n import _
 
 try:
@@ -28,33 +36,54 @@ try:
 except ImportError:
     import StringIO
 try:
-    import StructuredText
+    from StructuredText.StructuredText import HTML as StructuredText
 except ImportError:
-    StructuredText = None
+    try: # older version
+        import StructuredText
+    except ImportError:
+        StructuredText = None
+try:
+    from docutils.core import publish_parts as ReStructuredText
+except ImportError:
+    ReStructuredText = None
 
 # bring in the templating support
-from roundup.cgi.PageTemplates import PageTemplate
+from roundup.cgi.PageTemplates import PageTemplate, GlobalTranslationService
 from roundup.cgi.PageTemplates.Expressions import getEngine
-from roundup.cgi.TAL.TALInterpreter import TALInterpreter
-from roundup.cgi import ZTUtils
+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):
+    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 'You are not allowed to %s items of class %s'%(self.action,
-            self.klass)
+        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, extension):
-    ''' Find a template in the nominated dir
-    '''
+def find_template(dir, name, view):
+    """ Find a template in the nominated dir
+    """
     # find the source
-    if extension:
-        filename = '%s.%s'%(name, extension)
+    if view:
+        filename = '%s.%s'%(name, view)
     else:
         filename = name
 
@@ -63,18 +92,19 @@ def find_template(dir, name, extension):
     if os.path.exists(src):
         return (src, filename)
 
-    # try with a .html extension (new-style)
-    filename = filename + '.html'
-    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 extension == no generic template is possible
-    if not extension:
+    # 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'%extension
+    generic = '_generic.%s'%view
     src = os.path.join(dir, generic)
     if os.path.exists(src):
         return (src, generic)
@@ -86,7 +116,7 @@ def find_template(dir, name, extension):
         return (src, generic)
 
     raise NoTemplate, 'No template file exists for templating "%s" '\
-        'with template "%s" (neither "%s" nor "%s")'%(name, extension,
+        'with template "%s" (neither "%s" nor "%s")'%(name, view,
         filename, generic)
 
 class Templates:
@@ -96,18 +126,32 @@ class Templates:
         self.dir = dir
 
     def precompileTemplates(self):
-        ''' Go through a directory and precompile all the templates therein
-        '''
+        """ Go through a directory and precompile all the templates therein
+        """
         for filename in os.listdir(self.dir):
-            if os.path.isdir(filename): continue
+            # 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('.')
+                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.
+        """ 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
@@ -115,7 +159,7 @@ class Templates:
 
             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'
@@ -156,11 +200,10 @@ class Templates:
         except NoTemplate, message:
             raise KeyError, message
 
-class RoundupPageTemplate(PageTemplate.PageTemplate):
-    '''A Roundup-specific PageTemplate.
+def context(client, template=None, classname=None, request=None):
+    """Return the rendering context dictionary
 
-    Interrogate the client to set up the various template variables to
-    be available:
+    The dictionary includes following symbols:
 
     *context*
      this is one of three things:
@@ -170,57 +213,97 @@ class RoundupPageTemplate(PageTemplate.PageTemplate):
         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. 
+         ``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.
-    '''
-    def getContext(self, client, classname, request):
-        # construct the TemplatingUtils class
-        utils = TemplatingUtils
-        if hasattr(client.instance.interfaces, 'TemplatingUtils'):
-            class utils(client.instance.interfaces.TemplatingUtils, utils):
-                pass
 
-        c = {
-             'options': {},
-             'nothing': None,
-             'request': request,
-             'db': HTMLDatabase(client),
-             'config': client.instance.config,
-             'tracker': client.instance,
-             'utils': utils(client),
-             'templates': Templates(client.instance.config.TEMPLATES),
-             'template': self,
-        }
-        # add in the item if there is one
-        if client.nodeid:
-            if classname == 'user':
-                c['context'] = HTMLUser(client, classname, client.nodeid,
-                    anonymous=1)
-            else:
-                c['context'] = HTMLItem(client, classname, client.nodeid,
-                    anonymous=1)
-        elif client.db.classes.has_key(classname):
-            if classname == 'user':
-                c['context'] = HTMLUserClass(client, classname, anonymous=1)
-            else:
-                c['context'] = HTMLClass(client, classname, anonymous=1)
-        return c
+    *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"""
@@ -235,14 +318,12 @@ class RoundupPageTemplate(PageTemplate.PageTemplate):
                 'Page Template %s has errors.'%self.id
 
         # figure the context
-        classname = classname or client.classname
-        request = request or HTMLRequest(client)
-        c = self.getContext(client, classname, request)
+        c = context(client, self, classname, request)
         c.update({'options': options})
 
         # and go
         output = StringIO.StringIO()
-        TALInterpreter(self._v_program, self.macros,
+        TALInterpreter.TALInterpreter(self._v_program, self.macros,
             getEngine().getContext(c), output, tal=1, strictinsert=0)()
         return output.getvalue()
 
@@ -250,10 +331,11 @@ class RoundupPageTemplate(PageTemplate.PageTemplate):
         return '<Roundup PageTemplate %r>'%self.id
 
 class HTMLDatabase:
-    ''' Return HTMLClasses for valid class fetches
-    '''
+    """ 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
@@ -265,15 +347,9 @@ class HTMLDatabase:
         if m:
             cl = m.group('cl')
             self._client.db.getclass(cl)
-            if cl == 'user':
-                klass = HTMLUser
-            else:
-                klass = HTMLItem
-            return klass(self._client, cl, m.group('id'))
+            return HTMLItem(self._client, cl, m.group('id'))
         else:
             self._client.db.getclass(item)
-            if item == 'user':
-                return HTMLUserClass(self._client, item)
             return HTMLClass(self._client, item)
 
     def __getattr__(self, attr):
@@ -287,85 +363,77 @@ class HTMLDatabase:
         l.sort()
         m = []
         for item in l:
-            if item == 'user':
-                m.append(HTMLUserClass(self._client, item))
             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
+num_re = re.compile('^-?\d+$')
+
+def lookupIds(db, prop, ids, fail_ok=0, num_re=num_re, do_lookup=True):
+    """ "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)
-    '''
+        "do_lookup" is there for preventing lookup by key-value (if we
+        know that the value passed *is* an id)
+    """
     cl = db.getclass(prop.classname)
     l = []
     for entry in ids:
-        if num_re.match(entry):
-            l.append(entry)
-        else:
+        if do_lookup:
             try:
-                l.append(cl.lookup(entry))
+                item = cl.lookup(entry)
             except (TypeError, KeyError):
-                if fail_ok:
-                    # pass through the bad value
-                    l.append(entry)
+                pass
+            else:
+                l.append(item)
+                continue
+        # 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
+def lookupKeys(linkcl, key, ids, num_re=num_re):
+    """ 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):
-            l.append(linkcl.get(entry, key))
+            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
 
-class HTMLPermissions:
-    ''' Helpers that provide answers to commonly asked Permission questions.
-    '''
-    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)
-
-    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 Edit) the current class?
-        '''
-        return self.is_view_ok() and not self.is_edit_ok()
-
-    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)
-
-    def edit_check(self):
-        ''' Raise the Unauthorised exception if the user's not permitted to
-            edit this class.
-        '''
-        if not self.is_edit_ok():
-            raise Unauthorised("edit", self._classname)
+def _set_input_default_args(dic):
+    # 'text' is the default value anyway --
+    # but for CSS usage it should be present
+    dic.setdefault('type', 'text')
+    # useful e.g for HTML LABELs:
+    if not dic.has_key('id'):
+        try:
+            if dic['text'] in ('radio', 'checkbox'):
+                dic['id'] = '%(name)s-%(value)s' % dic
+            else:
+                dic['id'] = dic['name']
+        except KeyError:
+            pass
 
 def input_html4(**attrs):
     """Generate an 'input' (html4) element with given attributes"""
-    return '<input %s>'%' '.join(['%s="%s"'%item for item in attrs.items()])
+    _set_input_default_args(attrs)
+    return '<input %s>'%' '.join(['%s="%s"'%(k,cgi.escape(str(v), True))
+        for k,v 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()])
+    _set_input_default_args(attrs)
+    return '<input %s/>'%' '.join(['%s="%s"'%(k,cgi.escape(str(v), True))
+        for k,v in attrs.items()])
 
 class HTMLInputMixin:
-    ''' requires a _client property '''
+    """ requires a _client property """
     def __init__(self):
         html_version = 'html4'
         if hasattr(self._client.instance.config, 'HTML_VERSION'):
@@ -374,12 +442,44 @@ class HTMLInputMixin:
             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>*)
-    '''
+    """ 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
 
@@ -391,13 +491,29 @@ class HTMLClass(HTMLInputMixin, HTMLPermissions):
 
         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)
+        """ return an HTMLProperty instance
+        """
 
         # we don't exist
         if item == 'id':
@@ -432,40 +548,35 @@ class HTMLClass(HTMLInputMixin, HTMLPermissions):
                     value = []
                 else:
                     value = None
-            return htmlklass(self._client, self._classname, '', prop, item,
+            return htmlklass(self._client, self._classname, None, prop, item,
                 value, self._anonymous)
 
         # no good
         raise KeyError, item
 
     def __getattr__(self, attr):
-        ''' convenience access '''
+        """ convenience access """
         try:
             return self[attr]
         except KeyError:
             raise AttributeError, attr
 
     def designator(self):
-        ''' Return this class' designator (classname) '''
+        """ 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.
-        '''
+    def getItem(self, itemid, num_re=num_re):
+        """ 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)
 
-        if self.classname == 'user':
-            klass = HTMLUser
-        else:
-            klass = HTMLItem
-
-        return klass(self._client, self.classname, itemid)
+        return HTMLItem(self._client, self.classname, itemid)
 
     def properties(self, sort=1):
-        ''' Return HTMLProperty for all of this class' properties.
-        '''
+        """ Return HTMLProperty for all of this class' properties.
+        """
         l = []
         for name, prop in self._props.items():
             for klass, htmlklass in propclasses:
@@ -481,30 +592,28 @@ class HTMLClass(HTMLInputMixin, HTMLPermissions):
         return l
 
     def list(self, sort_on=None):
-        ''' List all items in this class.
-        '''
-        if self.classname == 'user':
-            klass = HTMLUser
-        else:
-            klass = HTMLItem
-
+        """ 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)
+        sortfunc = make_sort_function(self._db, self._classname, sort_on)
         l.sort(sortfunc)
 
-        l = [klass(self._client, self.classname, x) for x in l]
+        # 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.
-        '''
-        if rcsv.error:
-            return rcsv.error
-
+        """ Return the items of this class as a chunk of CSV text.
+        """
         props = self.propnames()
         s = StringIO.StringIO()
-        writer = rcsv.writer(s, rcsv.comma_separated)
+        writer = csv.writer(s)
         writer.writerow(props)
         for nodeid in self._klass.list():
             l = []
@@ -520,82 +629,127 @@ class HTMLClass(HTMLInputMixin, HTMLPermissions):
         return s.getvalue()
 
     def propnames(self):
-        ''' Return the list of the names of the properties of this class.
-        '''
+        """ 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
+    def filter(self, request=None, filterspec={}, sort=[], group=[]):
+        """ 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
-        if self.classname == 'user':
-            klass = HTMLUser
-        else:
-            klass = HTMLItem
-        l = [klass(self._client, self.classname, x)
-             for x in self._klass.filter(None, filterspec, sort, 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=''):
-        ''' Pop up a javascript window with class help
+    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).
+        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 and
-            height. The popup window will be resizable and scrollable.
+        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).
 
-            If the "property" arg is given, it's passed through to the
-            javascript help_window function.
-        '''
+        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
-        return '<a class="classhelp" href="javascript:help_window(\'%s?'\
-            '@startwith=0&amp;@template=help&amp;properties=%s%s\', \'%s\', \
-            \'%s\')">%s</a>'%(self.classname, properties, property, width,
-            height, label)
-
-    def submit(self, label="Submit New Entry"):
-        ''' Generate a submit button (and action hidden element)
-        '''
-        self.view_check()
-        if self.is_edit_ok():
-            return self.input(type="hidden",name="@action",value="new") + \
-                   '\n' + self.input(type="submit",name="submit",value=label)
-        return ''
+        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", action="new"):
+        """ 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=action) + \
+            '\n' + \
+            self.input(type="submit", name="submit_button", value=self._(label))
 
     def history(self):
-        self.view_check()
-        return 'New node - no history'
+        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.
-        '''
+        """ 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 = Templates(self._db.config.TEMPLATES).get(self.classname, name)
+        pt = self._client.instance.templates.get(self.classname, name)
 
         # use our fabricated request
         args = {
@@ -604,9 +758,9 @@ class HTMLClass(HTMLInputMixin, HTMLPermissions):
         }
         return pt.render(self._client, self.classname, req, **args)
 
-class HTMLItem(HTMLInputMixin, HTMLPermissions):
-    ''' Accesses through an *item*
-    '''
+class _HTMLItem(HTMLInputMixin, HTMLPermissions):
+    """ Accesses through an *item*
+    """
     def __init__(self, client, classname, nodeid, anonymous=0):
         self._client = client
         self._db = client.db
@@ -620,38 +774,71 @@ class HTMLItem(HTMLInputMixin, HTMLPermissions):
 
         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)
+        """ return an HTMLProperty instance
+            this now can handle transitive lookups where item is of the
+            form x.y.z
+        """
         if item == 'id':
             return self._nodeid
 
+        items = item.split('.', 1)
+        has_rest = len(items) > 1
+
         # get the property
-        prop = self._props[item]
+        prop = self._props[items[0]]
+
+        if has_rest and not isinstance(prop, (hyperdb.Link, hyperdb.Multilink)):
+            raise KeyError, item
 
         # get the value, handling missing values
         value = None
         if int(self._nodeid) > 0:
-            value = self._klass.get(self._nodeid, item, None)
+            value = self._klass.get(self._nodeid, items[0], None)
         if value is None:
-            if isinstance(self._props[item], hyperdb.Multilink):
+            if isinstance(prop, hyperdb.Multilink):
                 value = []
 
         # look up the correct HTMLProperty class
+        htmlprop = None
         for klass, htmlklass in propclasses:
             if isinstance(prop, klass):
-                return htmlklass(self._client, self._classname,
-                    self._nodeid, prop, item, value, self._anonymous)
+                htmlprop = htmlklass(self._client, self._classname,
+                    self._nodeid, prop, items[0], value, self._anonymous)
+        if htmlprop is not None:
+            if has_rest:
+                if isinstance(htmlprop, MultilinkHTMLProperty):
+                    return [h[items[1]] for h in htmlprop]
+                return htmlprop[items[1]]
+            return htmlprop
 
         raise KeyError, item
 
     def __getattr__(self, attr):
-        ''' convenience access to properties '''
+        """ convenience access to properties """
         try:
             return self[attr]
         except KeyError:
@@ -664,63 +851,58 @@ class HTMLItem(HTMLInputMixin, HTMLPermissions):
     def is_retired(self):
         """Is this item retired?"""
         return self._klass.is_retired(self._nodeid)
-    
-    def submit(self, label="Submit Changes"):
+
+    def submit(self, label=''"Submit Changes", action="edit"):
         """Generate a submit button.
 
         Also sneak in the lastactivity and action hidden elements.
         """
-        return self.input(type="hidden", name="@lastactivity", value=date.Date('.')) + '\n' + \
-               self.input(type="hidden", name="@action", value="edit") + '\n' + \
-               self.input(type="submit", name="submit", value=label)
+        return self.input(type="hidden", name="@lastactivity",
+            value=self.activity.local(0)) + '\n' + \
+            self.input(type="hidden", name="@action", value=action) + '\n' + \
+            self.input(type="submit", name="submit_button", value=self._(label))
 
     def journal(self, direction='descending'):
-        ''' Return a list of HTMLJournalEntry instances.
-        '''
+        """ Return a list of HTMLJournalEntry instances.
+        """
         # XXX do this
         return []
 
-    def history(self, direction='descending', dre=re.compile('\d+')):
-        self.view_check()
+    def history(self, direction='descending', dre=re.compile('^\d+$')):
+        if not self.is_view_ok():
+            return self._('[hidden]')
 
-        l = ['<table class="history">'
-             '<tr><th colspan="4" class="header">',
-             _('History'),
-             '</th></tr><tr>',
-             _('<th>Date</th>'),
-             _('<th>User</th>'),
-             _('<th>Action</th>'),
-             _('<th>Args</th>'),
-            '</tr>']
+        # pre-load the history with the current state
         current = {}
-        comments = {}
+        for prop_n in self._props.keys():
+            prop = self[prop_n]
+            if not isinstance(prop, HTMLProperty):
+                continue
+            current[prop_n] = prop.plain(escape=1)
+            # 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()
-        if direction == 'descending':
-            history.reverse()
-            # pre-load the history with the current state
-            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])
+        l = []
+        comments = {}
         for id, evt_date, user, action, args in history:
             date_s = str(evt_date.local(timezone)).replace("."," ")
             arg_s = ''
@@ -751,9 +933,10 @@ class HTMLItem(HTMLInputMixin, HTMLPermissions):
                         prop = None
                     if prop is None:
                         # property no longer exists
-                        comments['no_exist'] = _('''<em>The indicated property
-                            no longer exists</em>''')
-                        cell.append('<em>%s: %s</em>\n'%(k, str(args[k])))
+                        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
@@ -764,8 +947,9 @@ class HTMLItem(HTMLInputMixin, HTMLPermissions):
                             linkcl = self._db.getclass(classname)
                         except KeyError:
                             labelprop = None
-                            comments[classname] = _('''The linked class
-                                %(classname)s no longer exists''')%locals()
+                            comments[classname] = self._(
+                                "The linked class %(classname)s no longer exists"
+                            ) % locals()
                         labelprop = linkcl.labelprop(1)
                         try:
                             template = find_template(self._db.config.TEMPLATES,
@@ -795,19 +979,23 @@ class HTMLItem(HTMLInputMixin, HTMLPermissions):
                                     if labelprop is not None and \
                                             labelprop != 'id':
                                         label = linkcl.get(linkid, labelprop)
+                                        label = cgi.escape(label)
                                 except IndexError:
-                                    comments['no_link'] = _('''<strike>The
-                                        linked node no longer
-                                        exists</strike>''')
+                                    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'%(k, ', '.join(ml)))
+                        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
@@ -815,61 +1003,68 @@ class HTMLItem(HTMLInputMixin, HTMLPermissions):
                         # there's no labelprop!
                         if labelprop is not None and labelprop != 'id':
                             try:
-                                label = linkcl.get(args[k], labelprop)
+                                label = cgi.escape(linkcl.get(args[k],
+                                    labelprop))
                             except IndexError:
-                                comments['no_link'] = _('''<strike>The
-                                    linked node no longer
-                                    exists</strike>''')
+                                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)
+                                old = '<a href="%s%s">%s</a>'%(classname,
+                                    args[k], label)
                             else:
                                 old = label;
-                            cell.append('%s: %s' % (k,old))
+                            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]:
-                        d = date.Date(args[k]).local(timezone)
-                        cell.append('%s: %s'%(k, str(d)))
+                        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]))
-                        cell.append('%s: %s'%(k, val))
+                        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'%(k, val))
+                        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'%(k, val))
+                        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'%(k, current[k]))
+                            cell.append('%s: %s'%(self._(k), current[k]))
                             current[k] = '(no value)'
                         else:
-                            cell.append('%s: (no value)'%k)
+                            cell.append(self._('%s: (no value)')%self._(k))
 
                     else:
-                        cell.append('%s: %s'%(k, str(args[k])))
+                        cell.append('%s: %s'%(self._(k), str(args[k])))
                         if current.has_key(k):
                             cell[-1] += ' -> %s'%current[k]
                             current[k] = str(args[k])
@@ -877,8 +1072,9 @@ class HTMLItem(HTMLInputMixin, HTMLPermissions):
                 arg_s = '<br />'.join(cell)
             else:
                 # unkown event!!
-                comments['unknown'] = _('''<strong><em>This event is not
-                    handled by the history display!</em></strong>''')
+                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
@@ -886,17 +1082,31 @@ class HTMLItem(HTMLInputMixin, HTMLPermissions):
             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, action, arg_s))
+                date_s, user, self._(action), arg_s))
         if comments:
-            l.append(_('<tr><td colspan=4><strong>Note:</strong></td></tr>'))
+            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.
-        '''
+        """ 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')
@@ -905,81 +1115,73 @@ class HTMLItem(HTMLInputMixin, HTMLPermissions):
             '&@queryname=%s'%urllib.quote(name))
 
         # new template, using the specified classname and request
-        pt = Templates(self._db.config.TEMPLATES).get(req.classname, 'search')
+        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
+        """ 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
 
-class HTMLUserPermission:
-
-    def is_edit_ok(self):
-        ''' Is the user allowed to Edit the current class?
-            Also check whether this is the current user's info.
-        '''
-        return self._user_perm_check('Edit')
-
-    def is_view_ok(self):
-        ''' Is the user allowed to View the current class?
-            Also check whether this is the current user's info.
-        '''
-        return self._user_perm_check('View')
-
-    def _user_perm_check(self, type):
-        # some users may view / edit all users
-        s = self._db.security
-        userid = self._client.userid
-        if s.hasPermission(type, userid, self._classname):
-            return 1
-
-        # users may view their own info
-        is_anonymous = self._db.user.get(userid, 'username') == 'anonymous'
-        if getattr(self, '_nodeid', None) == userid and not is_anonymous:
-            return 1
-
-        # may anonymous users register? (so, they need to be anonymous,
-        # need the Web Rego permission, and not trying to view an item)
-        rego = s.hasPermission('Web Registration', userid, self._classname)
-        if is_anonymous and rego and getattr(self, '_nodeid', None) is None:
-            return 1
-
-        # nope, no access here
-        return 0
-
-class HTMLUserClass(HTMLUserPermission, HTMLClass):
-    pass
-
-class HTMLUser(HTMLUserPermission, HTMLItem):
-    ''' Accesses through the *user* (a special case of item)
-    '''
-    def __init__(self, client, classname, nodeid, anonymous=0):
-        HTMLItem.__init__(self, client, 'user', nodeid, anonymous)
-        self._default_classname = client.classname
-
-        # used for security checks
-        self._security = client.db.security
+        "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):
-        ''' Determine if the user has the Permission.
+    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.
-        '''
+        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._default_classname
-        return self._security.hasPermission(permission, self._nodeid, classname)
+            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
+    """ String, Number, Date, Interval HTMLProperty
 
         Has useful attributes:
 
@@ -987,11 +1189,12 @@ class HTMLProperty(HTMLInputMixin, HTMLPermissions):
          _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
@@ -1015,66 +1218,109 @@ class HTMLProperty(HTMLInputMixin, HTMLPermissions):
             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 None?'''
-        return self._value is None
+        """Is my _value not None?"""
+        return self._value is not None
 
     def is_edit_ok(self):
-        ''' Is the user allowed to Edit the current class?
-        '''
-        thing = HTMLDatabase(self._client)[self._classname]
+        """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:
-            # this is a special-case for the User class where permission's
-            # on a per-item basis :(
-            thing = thing.getItem(self._nodeid)
-        return thing.is_edit_ok()
+            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?
-        '''
-        thing = HTMLDatabase(self._client)[self._classname]
-        if self._nodeid:
-            # this is a special-case for the User class where permission's
-            # on a per-item basis :(
-            thing = thing.getItem(self._nodeid)
-        return thing.is_view_ok()
+        """ 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-z_]+)(?P<id>\d+)))')
+    hyper_re = re.compile(r'''(
+        (?P<url>
+         (
+          (ht|f)tp(s?)://                   # protocol
+          ([\w]+(:\w+)?@)?                  # username/password
+          ([\w\-]+)                         # hostname
+          ((\.[\w-]+)+)?                    # .domain.etc
+         |                                  # ... or ...
+          ([\w]+(:\w+)?@)?                  # username/password
+          www\.                             # "www."
+          ([\w\-]+\.)+                      # hostname
+          [\w]{2,5}                         # TLD
+         )
+         (:[\d]{1,5})?                     # port
+         (/[\w\-$.+!*(),;:@&=?/~\\#%]*)?   # path etc.
+        )|
+        (?P<email>[-+=%/\w\.]+@[\w\.\-]+)|
+        (?P<item>(?P<class>[A-Za-z_]+)(\s*)(?P<id>\d+))
+    )''', re.X | re.I)
+    protocol_re = re.compile('^(ht|f)tp(s?)://', re.I)
+
+    def _hyper_repl_item(self,match,replacement):
+        item = match.group('item')
+        cls = match.group('class').lower()
+        id = match.group('id')
+        try:
+            # make sure cls is a valid tracker classname
+            cl = self._db.getclass(cls)
+            if not cl.hasnode(id):
+                return item
+            return replacement % locals()
+        except KeyError:
+            return item
+
     def _hyper_repl(self, match):
         if match.group('url'):
-            s = match.group('url')
-            return '<a href="%s">%s</a>'%(s, s)
+            u = s = match.group('url')
+            if not self.protocol_re.search(s):
+                u = 'http://' + s
+            # catch an escaped ">" at the end of the URL
+            if s.endswith('&gt;'):
+                u = s = s[:-4]
+                e = '&gt;'
+            else:
+                e = ''
+            return '<a href="%s">%s</a>%s'%(u, s, e)
         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')
-            s2 = match.group('id')
-            try:
-                # make sure s1 is a valid tracker classname
-                cl = self._db.getclass(s1)
-                if not cl.hasnode(s2):
-                    raise KeyError, 'oops'
-                return '<a href="%s">%s%s</a>'%(s, s1, s2)
-            except KeyError:
-                return '%s%s'%(s1, s2)
+            return self._hyper_repl_item(match,
+                '<a href="%(cls)s%(id)s">%(item)s</a>')
+
+    def _hyper_repl_rst(self, match):
+        if match.group('url'):
+            s = match.group('url')
+            return '`%s <%s>`_'%(s, s)
+        elif match.group('email'):
+            s = match.group('email')
+            return '`%s <mailto:%s>`_'%(s, s)
+        else:
+            return self._hyper_repl_item(match,'`%(item)s <%(cls)s%(id)s>`_')
 
     def hyperlinked(self):
-        ''' Render a "hyperlinked" version of the text '''
+        """ 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
-            
+        """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
-        '''
-        self.view_check()
+        """
+        if not self.is_view_ok():
+            return self._('[hidden]')
 
         if self._value is None:
             return ''
@@ -1089,66 +1335,113 @@ class StringHTMLProperty(HTMLProperty):
             s = self.hyper_re.sub(self._hyper_repl, s)
         return s
 
-    def stext(self, escape=0):
-        ''' Render the value of the property as StructuredText.
+    def wrapped(self, escape=1, hyperlink=1):
+        """Render a "wrapped" representation of the property.
+
+        We wrap long lines at 80 columns on the nearest whitespace. Lines
+        with no whitespace are not broken to force wrapping.
+
+        Note that unlike plain() we default wrapped() to have the escaping
+        and hyperlinking turned on since that's the most common usage.
+
+        - "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 ''
+        s = support.wrap(str(self._value), width=80)
+        if escape:
+            s = cgi.escape(s)
+        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.
-        '''
-        self.view_check()
+        """
+        if not self.is_view_ok():
+            return self._('[hidden]')
 
-        s = self.plain(escape=escape)
+        s = self.plain(escape=escape, hyperlink=hyperlink)
         if not StructuredText:
             return s
         return StructuredText(s,level=1,header=0)
 
-    def field(self, size = 30):
-        ''' Render the property as a field in HTML.
+    def rst(self, hyperlink=1):
+        """ Render the value of the property as ReStructuredText.
+
+            This requires docutils to be installed separately.
+        """
+        if not self.is_view_ok():
+            return self._('[hidden]')
+
+        if not ReStructuredText:
+            return self.plain(escape=0, hyperlink=hyperlink)
+        s = self.plain(escape=0, hyperlink=0)
+        if hyperlink:
+            s = self.hyper_re.sub(self._hyper_repl_rst, s)
+        return ReStructuredText(s, writer_name="html")["body"].encode("utf-8",
+            "replace")
+
+    def field(self, **kwargs):
+        """ Render the property as a field in HTML.
 
             If not editable, just display the value via plain().
-        '''
-        self.view_check()
+        """
+        if not self.is_edit_ok():
+            return self.plain(escape=1)
 
-        if self._value is None:
+        value = self._value
+        if value is None:
             value = ''
-        else:
-            value = cgi.escape(str(self._value))
-
-        if self.is_edit_ok():
-            value = '&quot;'.join(value.split('"'))
-            return self.input(name=self._formname,value=value,size=size)
 
-        return self.plain()
+        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.
+    def multiline(self, escape=0, rows=5, cols=40, **kwargs):
+        """ Render a multiline form edit field for the property.
 
             If not editable, just display the plain() value in a <pre> tag.
-        '''
-        self.view_check()
+        """
+        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))
 
-        if self.is_edit_ok():
             value = '&quot;'.join(value.split('"'))
-            return '<textarea name="%s" rows="%s" cols="%s">%s</textarea>'%(
-                self._formname, rows, cols, value)
-
-        return '<pre>%s</pre>'%self.plain()
+        name = self._formname
+        passthrough_args = ' '.join(['%s="%s"' % (k, cgi.escape(str(v), True))
+            for k,v in kwargs.items()])
+        return ('<textarea %(passthrough_args)s name="%(name)s" id="%(name)s"'
+                ' rows="%(rows)s" cols="%(cols)s">'
+                 '%(value)s</textarea>') % locals()
 
     def email(self, escape=1):
-        ''' Render the value of the property as an obscured email address
-        '''
-        self.view_check()
+        """ 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)
-        if value.find('@') != -1:
-            name, domain = value.split('@')
+        split = value.split('@')
+        if len(split) == 2:
+            name, domain = split
             domain = ' '.join(domain.split('.')[:-1])
             name = name.replace('.', ' ')
             value = '%s at %s ...'%(name, domain)
@@ -1159,231 +1452,337 @@ class StringHTMLProperty(HTMLProperty):
         return value
 
 class PasswordHTMLProperty(HTMLProperty):
-    def plain(self):
-        ''' Render a "plain" representation of the property
-        '''
-        self.view_check()
+    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 ''
-        return _('*encrypted*')
+        return self._('*encrypted*')
 
-    def field(self, size = 30):
-        ''' Render a form edit field for the property.
+    def field(self, size=30):
+        """ Render a form edit field for the property.
 
             If not editable, just display the value via plain().
-        '''
-        self.view_check()
-
-        if self.is_edit_ok():
-            return self.input(type="password", name=self._formname, size=size)
+        """
+        if not self.is_edit_ok():
+            return self.plain(escape=1)
 
-        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 
+    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@name".
 
             If not editable, display nothing.
-        '''
-        self.view_check()
-
-        if self.is_edit_ok():
-            return self.input(type="password",
-                name="@confirm@%s"%self._formname, size=size)
+        """
+        if not self.is_edit_ok():
+            return ''
 
-        return ''
+        return self.input(type="password",
+            name="@confirm@%s"%self._formname,
+            id="%s-confirm"%self._formname,
+            size=size)
 
 class NumberHTMLProperty(HTMLProperty):
-    def plain(self):
-        ''' Render a "plain" representation of the property
-        '''
-        self.view_check()
+    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 ''
 
         return str(self._value)
 
-    def field(self, size = 30):
-        ''' Render a form edit field for the property.
+    def field(self, size=30):
+        """ Render a form edit field for the property.
 
             If not editable, just display the value via plain().
-        '''
-        self.view_check()
+        """
+        if not self.is_edit_ok():
+            return self.plain(escape=1)
 
-        if self._value is None:
+        value = self._value
+        if value is None:
             value = ''
-        else:
-            value = cgi.escape(str(self._value))
-
-        if self.is_edit_ok():
-            value = '&quot;'.join(value.split('"'))
-            return self.input(name=self._formname,value=value,size=size)
 
-        return self.plain()
+        return self.input(name=self._formname, value=value, size=size)
 
     def __int__(self):
-        ''' Return an int of me
-        '''
+        """ Return an int of me
+        """
         return int(self._value)
 
     def __float__(self):
-        ''' Return a float of me
-        '''
+        """ Return a float of me
+        """
         return float(self._value)
 
 
 class BooleanHTMLProperty(HTMLProperty):
-    def plain(self):
-        ''' Render a "plain" representation of the property
-        '''
-        self.view_check()
+    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 ''
-        return self._value and "Yes" or "No"
+        return self._value and self._("Yes") or self._("No")
 
     def field(self):
-        ''' Render a form edit field for the property
+        """ Render a form edit field for the property
 
             If not editable, just display the value via plain().
-        '''
-        self.view_check()
-
+        """
         if not self.is_edit_ok():
-            return self.plain()
+            return self.plain(escape=1)
 
-        checked = self._value and "checked" or ""
-        if self._value:
+        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 += 'Yes'
+            s += self._('Yes')
             s +=self.input(type="radio", name=self._formname, value="no")
-            s += 'No'
+            s += self._('No')
         else:
             s = self.input(type="radio", name=self._formname, value="yes")
-            s += 'Yes'
+            s += self._('Yes')
             s +=self.input(type="radio", name=self._formname, value="no",
                 checked="checked")
-            s += 'No'
+            s += self._('No')
         return s
 
 class DateHTMLProperty(HTMLProperty):
-    def plain(self):
-        ''' Render a "plain" representation of the property
-        '''
-        self.view_check()
+
+    _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, escape=0):
+        """ 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.local(self._db.getUserTimezone()))
+        if self._offset is None:
+            offset = self._db.getUserTimezone()
+        else:
+            offset = self._offset
+        return str(self._value.local(offset))
 
-    def now(self):
-        ''' Return the current time.
+    def now(self, str_interval=None):
+        """ Return the current time.
 
             This is useful for defaulting a new value. Returns a
             DateHTMLProperty.
-        '''
-        self.view_check()
+        """
+        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, date.Date('.'))
+            self._prop, self._formname, ret)
 
-    def field(self, size = 30):
-        ''' Render a form edit field for the property
+    def field(self, size=30, default=None, format=_marker, popcal=True):
+        """Render a form edit field for the property
 
-            If not editable, just display the value via plain().
-        '''
-        self.view_check()
+        If not editable, just display the value via plain().
 
-        if self._value is None:
-            value = ''
+        If "popcal" then include the Javascript calendar editor.
+        Default=yes.
+
+        The format string is a standard python strftime format string.
+        """
+        if not self.is_edit_ok():
+            if format is self._marker:
+                return self.plain(escape=1)
+            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.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
+            if isinstance(value, unicode): value = value.encode('utf8')
+            return self.input(name=self._formname, value=value, size=size)
         else:
-            tz = self._db.getUserTimezone()
-            value = cgi.escape(str(self._value.local(tz)))
+            raw_value = value
 
-        if self.is_edit_ok():
-            value = '&quot;'.join(value.split('"'))
-            return self.input(name=self._formname,value=value,size=size)
-        
-        return self.plain()
+        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)
+
+        s = self.input(name=self._formname, value=value, size=size)
+        if popcal:
+            s += self.popcal()
+        return s
 
     def reldate(self, pretty=1):
-        ''' Render the interval between the date and now.
+        """ Render the interval between the date and now.
 
             If the "pretty" flag is true, then make the display pretty.
-        '''
-        self.view_check()
+        """
+        if not self.is_view_ok():
+            return self._('[hidden]')
 
         if not self._value:
             return ''
 
         # figure the interval
-        interval = self._value - date.Date('.')
+        interval = self._value - date.Date('.', translator=self._client)
         if pretty:
             return interval.pretty()
         return str(interval)
 
-    _marker = []
     def pretty(self, format=_marker):
-        ''' Render the date in a pretty format (eg. month names, spaces).
+        """ 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 situatin when a date only specifies a month and a year.
-        '''
-        self.view_check()
+            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 format is not self._marker:
-            return self._value.pretty(format)
+        if not self._value:
+            return ''
+        elif format is not self._marker:
+            return self._value.local(offset).pretty(format)
         else:
-            return self._value.pretty()
+            return self._value.local(offset).pretty()
 
     def local(self, offset):
-        ''' Return the date/time as a local (timezone offset) date/time.
-        '''
-        self.view_check()
+        """ 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.local(offset))
+            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 plain(self):
-        ''' Render a "plain" representation of the property
-        '''
-        self.view_check()
+    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, escape=0):
+        """ 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")
-        '''
-        self.view_check()
+        """ 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
+    def field(self, size=30):
+        """ Render a form edit field for the property
 
             If not editable, just display the value via plain().
-        '''
-        self.view_check()
+        """
+        if not self.is_edit_ok():
+            return self.plain(escape=1)
 
-        if self._value is None:
+        value = self._value
+        if value is None:
             value = ''
-        else:
-            value = cgi.escape(str(self._value))
-
-        if is_edit_ok():
-            value = '&quot;'.join(value.split('"'))
-            return self.input(name=self._formname,value=value,size=size)
 
-        return self.plain()
+        return self.input(name=self._formname, value=value, size=size)
 
 class LinkHTMLProperty(HTMLProperty):
-    ''' Link 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
@@ -1391,7 +1790,7 @@ class LinkHTMLProperty(HTMLProperty):
         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
@@ -1400,40 +1799,43 @@ class LinkHTMLProperty(HTMLProperty):
             self._value = None
 
     def __getattr__(self, attr):
-        ''' return a new HTMLItem '''
-       #print 'Link.getattr', (self, attr, self._value)
+        """ return a new HTMLItem """
         if not self._value:
-            raise AttributeError, "Can't access missing value"
-        if self._prop.classname == 'user':
-            klass = HTMLUser
-        else:
-            klass = HTMLItem
-        i = klass(self._client, self._prop.classname, 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
-        '''
-        self.view_check()
+        """ 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 num_re.match(self._value):
+            value = str(linkcl.get(self._value, k))
+        else :
+            value = self._value
         if escape:
             value = cgi.escape(value)
         return value
 
     def field(self, showid=0, size=None):
-        ''' Render a form edit field for the property
+        """ Render a form edit field for the property
 
             If not editable, just display the value via plain().
-        '''
-        self.view_check()
-
+        """
         if not self.is_edit_ok():
-            return self.plain()
+            return self.plain(escape=1)
 
         # edit field
         linkcl = self._db.getclass(self._prop.classname)
@@ -1441,27 +1843,38 @@ class LinkHTMLProperty(HTMLProperty):
             value = ''
         else:
             k = linkcl.getkey()
-            if k:
+            if k and num_re.match(self._value):
                 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)
+        return self.input(name=self._formname, value=value, size=size)
 
-    def menu(self, size=None, height=None, showid=0, additional=[],
+    def menu(self, size=None, height=None, showid=0, additional=[], value=None,
             sort_on=None, **conditions):
-        ''' Render a form select list for this property
+        """ 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().
-        '''
-        self.view_check()
-
+        """
         if not self.is_edit_ok():
-            return self.plain()
+            return self.plain(escape=1)
 
-        value = self._value
+        if value is None:
+            value = self._value
 
         linkcl = self._db.getclass(self._prop.classname)
         l = ['<select name="%s">'%self._formname]
@@ -1469,19 +1882,25 @@ class LinkHTMLProperty(HTMLProperty):
         s = ''
         if value is None:
             s = 'selected="selected" '
-        l.append(_('<option %svalue="-1">- no selection -</option>')%s)
-        if linkcl.getprops().has_key('order'):  
-            sort_on = ('+', 'order')
-        else:  
-            if sort_on is None:
-                sort_on = ('+', linkcl.labelprop())
-            else:
-                sort_on = ('+', sort_on)
-        options = linkcl.filter(None, conditions, sort_on, (None, None))
+        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 self._value and self._value not in options:
-            options.insert(0, self._value)
+        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
@@ -1495,6 +1914,8 @@ class LinkHTMLProperty(HTMLProperty):
             # 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
 
@@ -1508,88 +1929,106 @@ class LinkHTMLProperty(HTMLProperty):
                 lab = lab + ' (%s)'%', '.join(map(str, m))
 
             # and generate
-            lab = cgi.escape(lab)
+            lab = cgi.escape(self._(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
+    """ 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, do_lookup=False)
             sortfun = make_sort_function(self._db, self._prop.classname)
-            self._value.sort(sortfun)
-    
+            # 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 '''
+        """ length of the multilink """
         return len(self._value)
 
     def __getattr__(self, attr):
-        ''' no extended attribute accesses make sense here '''
+        """ no extended attribute accesses make sense here """
         raise AttributeError, attr
 
-    def __getitem__(self, num):
-        ''' iterate and return a new HTMLItem
-        '''
-       #print 'Multi.getitem', (self, num)
-        value = self._value[num]
-        if self._prop.classname == 'user':
-            klass = HTMLUser
-        else:
-            klass = HTMLItem
-        return klass(self._client, self._prop.classname, value)
+    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
+        """ 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 []?'''
-        return self._value == []
-
-    def reverse(self):
-        ''' return the list in reverse order
-        '''
-        l = self._value[:]
-        l.reverse()
-        if self._prop.classname == 'user':
-            klass = HTMLUser
-        else:
-            klass = HTMLItem
-        return [klass(self._client, self._prop.classname, value) for value in l]
+        """Is my _value not []?"""
+        return self._value != []
 
     def plain(self, escape=0):
-        ''' Render a "plain" representation of the property
-        '''
-        self.view_check()
+        """ 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:
-            labels.append(linkcl.get(v, k))
+            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
+        """ Render a form edit field for the property
 
             If not editable, just display the value via plain().
-        '''
-        self.view_check()
-
+        """
         if not self.is_edit_ok():
-            return self.plain()
+            return self.plain(escape=1)
 
         linkcl = self._db.getclass(self._prop.classname)
         value = self._value[:]
@@ -1599,28 +2038,51 @@ class MultilinkHTMLProperty(HTMLProperty):
         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)
+        value = ','.join(value)
+        return self.input(name=self._formname, size=size, value=value)
 
     def menu(self, size=None, height=None, showid=0, additional=[],
-            sort_on=None, **conditions):
-        ''' Render a form select list for this property
+             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().
-        '''
-        self.view_check()
-
+        """
         if not self.is_edit_ok():
-            return self.plain()
+            return self.plain(escape=1)
 
-        value = self._value
+        if value is None:
+            value = self._value
 
         linkcl = self._db.getclass(self._prop.classname)
-        if sort_on is None:
-            sort_on = ('+', find_sort_key(linkcl))
+
+        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 = ('+', sort_on)
-        options = linkcl.filter(None, conditions, sort_on)
+            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)
@@ -1654,7 +2116,7 @@ class MultilinkHTMLProperty(HTMLProperty):
                 lab = lab + ' (%s)'%', '.join(m)
 
             # and generate
-            lab = cgi.escape(lab)
+            lab = cgi.escape(self._(lab))
             l.append('<option %svalue="%s">%s</option>'%(s, optionid,
                 lab))
         l.append('</select>')
@@ -1673,8 +2135,8 @@ propclasses = (
 )
 
 def make_sort_function(db, classname, sort_on=None):
-    '''Make a sort function for a given class
-    '''
+    """Make a sort function for a given class
+    """
     linkcl = db.getclass(classname)
     if sort_on is None:
         sort_on = find_sort_key(linkcl)
@@ -1689,34 +2151,25 @@ def find_sort_key(linkcl):
         return linkcl.labelprop()
 
 def handleListCGIValue(value):
-    ''' Value is either a single item or a list of items. Each item has a
+    """ 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 ShowDict:
-    ''' A convenience access to the :columns index parameters
-    '''
-    def __init__(self, columns):
-        self.columns = {}
-        for col in columns:
-            self.columns[col] = 1
-    def __getitem__(self, name):
-        return self.columns.has_key(name)
+        return [v.strip() for v in value.split(',')]
 
 class HTMLRequest(HTMLInputMixin):
-    '''The *request*, holding the CGI form and environment.
+    """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 HTMLUser instance for this user
+    - "user" a HTMLItem instance for this user
+    - "language" as determined by the browser or config
     - "classname" the current classname (possibly None)
     - "template" the current template (suffix, also possibly None)
 
@@ -1730,7 +2183,10 @@ class HTMLRequest(HTMLInputMixin):
     - "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.__dict__
+
     def __init__(self, client):
         # _client is needed by HTMLInputMixin
         self._client = self.client = client
@@ -1739,10 +2195,12 @@ class HTMLRequest(HTMLInputMixin):
         self.form = client.form
         self.env = client.env
         self.base = client.base
-        self.user = HTMLUser(client, 'user', client.userid)
+        self.user = HTMLItem(client, 'user', client.userid)
+        self.language = client.language
 
         # 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
@@ -1752,9 +2210,55 @@ class HTMLRequest(HTMLInputMixin):
 
         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 _parse_sort(self, var, name):
+        """ Parse sort/group options. Append to var
+        """
+        fields = []
+        dirs = []
+        for special in '@:':
+            idx = 0
+            key = '%s%s%d'%(special, name, idx)
+            while key in self.form:
+                self.special_char = special
+                fields.append (self.form[key].value)
+                dirkey = '%s%sdir%d'%(special, name, idx)
+                if dirkey in self.form:
+                    dirs.append(self.form[dirkey].value)
+                else:
+                    dirs.append(None)
+                idx += 1
+                key = '%s%s%d'%(special, name, idx)
+            # backward compatible (and query) URL format
+            key = special + name
+            dirkey = key + 'dir'
+            if key in self.form and not fields:
+                fields = handleListCGIValue(self.form[key])
+                if dirkey in self.form:
+                    dirs.append(self.form[dirkey].value)
+            if fields: # only try other special char if nothing found
+                break
+        for f, d in map(None, fields, dirs):
+            if f.startswith('-'):
+                var.append(('-', f[1:]))
+            elif d:
+                var.append(('-', f))
+            else:
+                var.append(('+', f))
+
     def _post_init(self):
-        ''' Set attributes based on self.form
-        '''
+        """ Set attributes based on self.form
+        """
         # extract the index display information from the form
         self.columns = []
         for name in ':columns @columns'.split():
@@ -1762,33 +2266,13 @@ class HTMLRequest(HTMLInputMixin):
                 self.special_char = name[0]
                 self.columns = handleListCGIValue(self.form[name])
                 break
-        self.show = ShowDict(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])
+        self.show = support.TruthDict(self.columns)
 
-        # 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])
+        # sorting and grouping
+        self.sort = []
+        self.group = []
+        self._parse_sort(self.sort, 'sort')
+        self._parse_sort(self.group, 'group')
 
         # filtering
         self.filter = []
@@ -1800,11 +2284,11 @@ class HTMLRequest(HTMLInputMixin):
         self.filterspec = {}
         db = self.client.db
         if self.classname is not None:
-            props = db.getclass(self.classname).getprops()
+            cls = db.getclass (self.classname)
             for name in self.filter:
                 if not self.form.has_key(name):
                     continue
-                prop = props[name]
+                prop = cls.get_transitive_prop (name)
                 fv = self.form[name]
                 if (isinstance(prop, hyperdb.Link) or
                         isinstance(prop, hyperdb.Multilink)):
@@ -1813,6 +2297,9 @@ class HTMLRequest(HTMLInputMixin):
                 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
 
@@ -1821,7 +2308,13 @@ class HTMLRequest(HTMLInputMixin):
         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
+                try:
+                    self.search_text = self.form[name].value
+                except AttributeError:
+                    # http://psf.upfronthosting.co.za/roundup/meta/issue111
+                    # Multiple search_text, probably some kind of spambot.
+                    # Use first value.
+                    self.search_text = self.form[name][0].value
 
         # pagination - size and start index
         # figure batch args
@@ -1837,25 +2330,31 @@ class HTMLRequest(HTMLInputMixin):
                 self.special_char = name[0]
                 self.startwith = int(self.form[name].value)
 
+        # dispname
+        if self.form.has_key('@dispname'):
+            self.dispname = self.form['@dispname'].value
+        else:
+            self.dispname = None
+
     def updateFromURL(self, url):
-        ''' Parse the URL for query args, and update my attributes using the
+        """ 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
-        '''
+        """ Update my attributes using the keyword args
+        """
         self.__dict__.update(kwargs)
         if kwargs.has_key('columns'):
-            self.show = ShowDict(self.columns)
+            self.show = support.TruthDict(self.columns)
 
     def description(self):
-        ''' Return a description of the request - handle for the page title.
-        '''
+        """ 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:
@@ -1882,7 +2381,7 @@ class HTMLRequest(HTMLInputMixin):
         for k,v in self.env.items():
             e += '\n     %r=%r'%(k, v)
         d['env'] = e
-        return '''
+        return """
 form: %(form)s
 base: %(base)r
 classname: %(classname)r
@@ -1895,45 +2394,55 @@ search_text: %(search_text)r
 pagesize: %(pagesize)r
 startwith: %(startwith)r
 env: %(env)s
-'''%d
+"""%d
 
     def indexargs_form(self, columns=1, sort=1, group=1, filter=1,
-            filterspec=1):
-        ''' return the current index args as form elements '''
+            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")
+        def add(k, v):
+            l.append(self.input(type="hidden", name=k, value=v))
         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))
+            add(sc+'columns', ','.join(self.columns))
+        if sort:
+            val = []
+            for dir, attr in self.sort:
+                if dir == '-':
+                    val.append('-'+attr)
+                else:
+                    val.append(attr)
+            add(sc+'sort', ','.join (val))
+        if group:
+            val = []
+            for dir, attr in self.group:
+                if dir == '-':
+                    val.append('-'+attr)
+                else:
+                    val.append(attr)
+            add(sc+'group', ','.join (val))
         if filter and self.filter:
-            l.append(s%(sc+'filter', ','.join(self.filter)))
-        if filterspec:
+            add(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([]):
-                    l.append(s%(k, ','.join(v)))
+                    if isinstance(props[k], hyperdb.String):
+                        add(k, ' '.join(v))
+                    else:
+                        add(k, ','.join(v))
                 else:
-                    l.append(s%(k, v))
-        if 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))
+                    add(k, v)
+        if search_text and self.search_text:
+            add(sc+'search_text', self.search_text)
+        add(sc+'pagesize', self.pagesize)
+        add(sc+'startwith', self.startwith)
         return '\n'.join(l)
 
     def indexargs_url(self, url, args):
-        ''' Embed the current index args in a URL
-        '''
+        """ 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()]
 
@@ -1946,39 +2455,49 @@ env: %(env)s
         # 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.sort and not specials.has_key('sort'):
+            val = []
+            for dir, attr in self.sort:
+                if dir == '-':
+                    val.append('-'+attr)
+                else:
+                    val.append(attr)
+            l.append(sc+'sort=%s'%(','.join(val)))
+        if self.group and not specials.has_key('group'):
+            val = []
+            for dir, attr in self.group:
+                if dir == '-':
+                    val.append('-'+attr)
+                else:
+                    val.append(attr)
+            l.append(sc+'group=%s'%(','.join(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'%self.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
-        for k,v in self.filterspec.items():
-            if not args.has_key(k):
-                if type(v) == type([]):
-                    l.append('%s=%s'%(k, ','.join(v)))
-                else:
-                    l.append('%s=%s'%(k, v))
+        if self.classname and self.filterspec:
+            cls = self.client.db.getclass(self.classname)
+            for k,v in self.filterspec.items():
+                if not args.has_key(k):
+                    if type(v) == type([]):
+                        prop = cls.get_transitive_prop(k)
+                        if k != 'id' and isinstance(prop, 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 '''
+        return """
 <script type="text/javascript">
 submitted = false;
 function submit_once() {
@@ -1995,11 +2514,11 @@ 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
+"""%self.base
 
     def batch(self):
-        ''' Return a batch object for results from the "current search"
-        '''
+        """ Return a batch object for results from the "current search"
+        """
         filterspec = self.filterspec
         sort = self.sort
         group = self.group
@@ -2008,10 +2527,18 @@ function help_window(helpurl, width, height) {
         klass = self.client.db.getclass(self.classname)
         if self.search_text:
             matches = self.client.db.indexer.search(
-                re.findall(r'\b\w{2,25}\b', self.search_text), klass)
+                [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
-        l = klass.filter(matches, filterspec, sort, group)
+
+        # 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,
@@ -2020,7 +2547,7 @@ function help_window(helpurl, width, height) {
 # 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
+    """ Use me to turn a list of items, or item ids of a given class, into a
         series of batches.
 
         ========= ========================================================
@@ -2042,7 +2569,7 @@ class Batch(ZTUtils.Batch):
         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
@@ -2058,7 +2585,7 @@ class Batch(ZTUtils.Batch):
         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
 
@@ -2071,20 +2598,27 @@ class Batch(ZTUtils.Batch):
         item = self._sequence[index + self.first]
         if self.classname:
             # map the item ids to instances
-            if self.classname == 'user':
-                item = HTMLUser(self.client, self.classname, item)
-            else:
-                item = HTMLItem(self.client, self.classname, item)
+            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
-        '''
-        if (self.last_item is None or
-                self.last_item[property] != self.current_item[property]):
+    def propchanged(self, *properties):
+        """ Detect if one of the properties marked as being a 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:
             return 1
+        for property in properties:
+            if property == 'id' or isinstance (self.last_item[property], list):
+                if (str(self.last_item[property]) !=
+                    str(self.current_item[property])):
+                    return 1
+            else:
+                if (self.last_item[property]._value !=
+                    self.current_item[property]._value):
+                    return 1
         return 0
 
     # override these 'cos we don't have access to acquisition
@@ -2104,8 +2638,8 @@ class Batch(ZTUtils.Batch):
             self.end - self.overlap, 0, self.orphan, self.overlap)
 
 class TemplatingUtils:
-    ''' Utilities for templating
-    '''
+    """ Utilities for templating
+    """
     def __init__(self, client):
         self.client = client
     def Batch(self, sequence, size, start, end=0, orphan=0, overlap=0):
@@ -2113,10 +2647,125 @@ class TemplatingUtils:
             overlap)
 
     def url_quote(self, url):
-        '''URL-quote the supplied text.'''
+        """URL-quote the supplied text."""
         return urllib.quote(url)
 
     def html_quote(self, html):
-        '''HTML-quote the supplied text.'''
-        return cgi.escape(url)
-
+        """HTML-quote the supplied text."""
+        return cgi.escape(html)
+
+    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 :
diff --git a/roundup/cgi/wsgi_handler.py b/roundup/cgi/wsgi_handler.py
new file mode 100644 (file)
index 0000000..3415d8c
--- /dev/null
@@ -0,0 +1,76 @@
+# WSGI interface for Roundup Issue Tracker
+#
+# This module is free software, you may redistribute it
+# and/or modify under the same terms as Python.
+#
+
+import os
+import cgi
+import weakref
+
+import roundup.instance
+from roundup.cgi import TranslationService
+from BaseHTTPServer import BaseHTTPRequestHandler
+
+
+class Writer(object):
+    '''Perform a start_response if need be when we start writing.'''
+    def __init__(self, request):
+        self.request = request #weakref.ref(request)
+    def write(self, data):
+        f = self.request.get_wfile()
+        self.write = f
+        return f(data)
+
+class RequestDispatcher(object):
+    def __init__(self, home, debug=False, timing=False, lang=None):
+        assert os.path.isdir(home), '%r is not a directory'%(home,)
+        self.home = home
+        self.debug = debug
+        self.timing = timing
+        if lang:
+            self.translator = TranslationService.get_translation(lang,
+                tracker_home=home)
+        else:
+            self.translator = None
+
+    def __call__(self, environ, start_response):
+        """Initialize with `apache.Request` object"""
+        self.environ = environ
+        request = RequestDispatcher(self.home, self.debug, self.timing)
+        request.__start_response = start_response
+
+        request.wfile = Writer(request)
+        request.__wfile = None
+
+        tracker = roundup.instance.open(self.home, not self.debug)
+
+        # need to strip the leading '/'
+        environ["PATH_INFO"] = environ["PATH_INFO"][1:]
+        if request.timing:
+            environ["CGI_SHOW_TIMING"] = request.timing
+
+        form = cgi.FieldStorage(fp=environ['wsgi.input'], environ=environ)
+
+        client = tracker.Client(tracker, request, environ, form,
+            request.translator)
+        try:
+            client.main()
+        except roundup.cgi.client.NotFound:
+            request.start_response([('Content-Type', 'text/html')], 404)
+            request.wfile.write('Not found: %s'%client.path)
+
+        # all body data has been written using wfile
+        return []
+
+    def start_response(self, headers, response_code):
+        """Set HTTP response code"""
+        description = BaseHTTPRequestHandler.responses[response_code]
+        self.__wfile = self.__start_response('%d %s'%(response_code,
+            description), headers)
+
+    def get_wfile(self):
+        if self.__wfile is None:
+            raise ValueError, 'start_response() not called'
+        return self.__wfile
+
diff --git a/roundup/configuration.py b/roundup/configuration.py
new file mode 100644 (file)
index 0000000..4fa4c94
--- /dev/null
@@ -0,0 +1,1344 @@
+# Roundup Issue Tracker configuration support
+#
+# $Id: configuration.py,v 1.51 2008-09-01 02:30:06 richard Exp $
+#
+__docformat__ = "restructuredtext"
+
+import ConfigParser
+import getopt
+import imp
+import logging, logging.config
+import os
+import re
+import sys
+import time
+import smtplib
+
+import roundup.date
+
+# XXX i don't think this module needs string translation, does it?
+
+### Exceptions
+
+class ConfigurationError(Exception):
+    pass
+
+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 AttributeError
+    (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 available (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)
+
+class TimezoneOption(Option):
+
+    class_description = \
+        "If pytz module is installed, value may be any valid\n" \
+        "timezone specification (e.g. EET or Europe/Warsaw).\n" \
+        "If pytz is not installed, value must be integer number\n" \
+        "giving local timezone offset from UTC in hours."
+
+    def str2value(self, value):
+        try:
+            roundup.date.get_timezone(value)
+        except KeyError:
+            raise OptionValueError(self, value,
+                    "Timezone name or numeric hour offset required")
+        return value
+
+class RegExpOption(Option):
+
+    """Regular Expression option (value is Regular Expression Object)"""
+
+    class_description = "Value is Python Regular Expression (UTF8-encoded)."
+
+    RE_TYPE = type(re.compile(""))
+
+    def __init__(self, config, section, setting,
+        default=NODEFAULT, description=None, aliases=None,
+        flags=0,
+    ):
+        self.flags = flags
+        Option.__init__(self, config, section, setting, default,
+            description, aliases)
+
+    def _value2str(self, value):
+        assert isinstance(value, self.RE_TYPE)
+        return value.pattern
+
+    def str2value(self, value):
+        if not isinstance(value, unicode):
+            value = str(value)
+            # if it is 7-bit ascii, use it as string,
+            # otherwise convert to unicode.
+            try:
+                value.decode("ascii")
+            except UnicodeError:
+                value = value.decode("utf-8")
+        return re.compile(value, self.flags)
+
+### 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\n"
+            "into trouble.\n"
+            "If no domain is specified then the config item\n"
+            "mail -> domain is added."),
+        (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.\n"
+            "If no domain is specified then the config item\n"
+            "mail -> domain is added."),
+        (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@tracker.example>\n"
+            "the EMAIL_FROM_TAG goes inside the \"Foo Bar\" quotes like so:\n"
+            "\"Foo Bar EMAIL_FROM_TAG\" <issue_tracker@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"),
+        (TimezoneOption, "timezone", "UTC", "Default timezone offset,"
+            " applied when user's timezone is not set.",
+            ["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.\n"
+            "If no domain is specified then mail_domain is added."),
+        (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,
+            "The email domain that admin_email, issue_tracker and\n"
+            "dispatcher_email belong to.\n"
+            "This domain is added to those config items if they don't\n"
+            "explicitly include a domain.\n"
+            "Do not include the '@' symbol."),
+        (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."),
+        (IntegerNumberOption, "port", smtplib.SMTP_PORT,
+            "Default port to send SMTP on.\n"
+            "Set this if your mail server runs on a different port."),
+        (NullableOption, "local_hostname", '',
+            "The local hostname to use during SMTP transmission.\n"
+            "Set this if your mail server requires something specific."),
+        (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."),
+        (BooleanOption, "add_authorinfo", "yes",
+            "Add a line with author information at top of all messages\n"
+            "sent by roundup"),
+        (BooleanOption, "add_authoremail", "yes",
+            "Add the mail address of the author to the author information at\n"
+            "the top of all messages.\n"
+            "If this is false but add_authorinfo is true, only the name\n"
+            "of the actor is added which protects the mail address of the\n"
+            "actor from being exposed at mail archives, etc."),
+    ), "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 prefix and \n"
+            'suffix in a subject line. The presence of "suffix" in\n'
+            "the config option name is a historical artifact and may\n"
+            "be ignored."),
+        (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."),
+        (RegExpOption, "refwd_re", "(\s*\W?\s*(fw|fwd|re|aw|sv|ang)\W)+",
+            "Regular expression matching a single reply or forward\n"
+            "prefix prepended by the mailer. This is explicitly\n"
+            "stripped from the subject during parsing."),
+        (RegExpOption, "origmsg_re",
+            "^[>|\s]*-----\s?Original Message\s?-----$",
+            "Regular expression matching start of an original message\n"
+            "if quoted the in body."),
+        (RegExpOption, "sign_re", "^[>|\s]*-- ?$",
+            "Regular expression matching the start of a signature\n"
+            "in the message body."),
+        (RegExpOption, "eol_re", r"[\r\n]+",
+            "Regular expression matching end of line."),
+        (RegExpOption, "blankline_re", r"[\r\n]+\s*[\r\n]+",
+            "Regular expression matching a blank line."),
+        (BooleanOption, "ignore_alternatives", "no",
+            "When parsing incoming mails, roundup uses the first\n"
+            "text/plain part it finds. If this part is inside a\n"
+            "multipart/alternative, and this option is set, all other\n"
+            "parts of the multipart/alternative are ignored. The default\n"
+            "is to keep all parts and attach them to the issue."),
+    ), "Roundup Mail Gateway options"),
+    ("pgp", (
+        (BooleanOption, "enable", "no",
+            "Enable PGP processing. Requires pyme."),
+        (NullableOption, "roles", "",
+            "If specified, a comma-separated list of roles to perform\n"
+            "PGP processing on. If not specified, it happens for all\n"
+            "users."),
+        (NullableOption, "homedir", "",
+            "Location of PGP directory. Defaults to $HOME/.gnupg if\n"
+            "not specified."),
+    ), "OpenPGP mail processing 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."),
+        (IntegerNumberOption, "max_attachment_size", sys.maxint,
+            "Attachments larger than the given number of bytes\n"
+            "won't be attached to nosy mails. They will be replaced by\n"
+            "a link to the tracker's download page for the file.")
+    ), "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 :
index 286936856a1cb0066e44c228ccae6c3dcc011cb9..7daf952d5a01364b2a4a3ad89e61de9a11885adf 100644 (file)
 # 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.66 2004-04-13 05:28:00 richard Exp $
+#
+# $Id: date.py,v 1.94 2007-12-23 00:23:23 richard Exp $
 
 """Date, time and time interval handling.
 """
 __docformat__ = 'restructuredtext'
 
-import time, re, calendar, types
-from types import *
-from i18n import _
+import calendar
+import datetime
+import time
+import re
 
-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
+try:
+    import pytz
+except ImportError:
+    pytz = None
+
+from roundup import i18n
+
+# 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)
+
+_timedelta0 = datetime.timedelta(0)
+
+# load UTC tzinfo
+if pytz:
+    UTC = pytz.utc
+else:
+    # fallback implementation from Python Library Reference
+
+    class _UTC(datetime.tzinfo):
+
+        """Universal Coordinated Time zoneinfo"""
+
+        def utcoffset(self, dt):
+            return _timedelta0
+
+        def tzname(self, dt):
+            return "UTC"
+
+        def dst(self, dt):
+            return _timedelta0
+
+        def __repr__(self):
+            return "<UTC>"
+
+        # pytz adjustments interface
+        # Note: pytz verifies that dt is naive datetime for localize()
+        # and not naive datetime for normalize().
+        # In this implementation, we don't care.
+
+        def normalize(self, dt, is_dst=False):
+            return dt.replace(tzinfo=self)
+
+        def localize(self, dt, is_dst=False):
+            return dt.replace(tzinfo=self)
+
+    UTC = _UTC()
+
+# integral hours offsets were available in Roundup versions prior to 1.1.3
+# and still are supported as a fallback if pytz module is not installed
+class SimpleTimezone(datetime.tzinfo):
+
+    """Simple zoneinfo with fixed numeric offset and no daylight savings"""
+
+    def __init__(self, offset=0, name=None):
+        super(SimpleTimezone, self).__init__()
+        self.offset = offset
+        if name:
+            self.name = name
+        else:
+            self.name = "Etc/GMT%+d" % self.offset
+
+    def utcoffset(self, dt):
+        return datetime.timedelta(hours=self.offset)
+
+    def tzname(self, dt):
+        return self.name
+
+    def dst(self, dt):
+        return _timedelta0
+
+    def __repr__(self):
+        return "<%s: %s>" % (self.__class__.__name__, self.name)
+
+    # pytz adjustments interface
+
+    def normalize(self, dt):
+        return dt.replace(tzinfo=self)
+
+    def localize(self, dt, is_dst=False):
+        return dt.replace(tzinfo=self)
+
+# simple timezones with fixed offset
+_tzoffsets = dict(GMT=0, UCT=0, EST=5, MST=7, HST=10)
+
+def get_timezone(tz):
+    # if tz is None, return None (will result in naive datetimes)
+    # XXX should we return UTC for None?
+    if tz is None:
+        return None
+    # try integer offset first for backward compatibility
+    try:
+        utcoffset = int(tz)
+    except (TypeError, ValueError):
+        pass
+    else:
+        if utcoffset == 0:
+            return UTC
+        else:
+            return SimpleTimezone(utcoffset)
+    # tz is a timezone name
+    if pytz:
+        return pytz.timezone(tz)
+    elif tz == "UTC":
+        return UTC
+    elif tz in _tzoffsets:
+        return SimpleTimezone(_tzoffsets[tz], tz)
+    else:
+        raise KeyError, tz
+
+def _utc_to_local(y,m,d,H,M,S,tz):
+    TZ = get_timezone(tz)
+    frac = S - int(S)
+    dt = datetime.datetime(y, m, d, H, M, int(S), tzinfo=UTC)
+    y,m,d,H,M,S = dt.astimezone(TZ).timetuple()[:6]
+    S = S + frac
+    return (y,m,d,H,M,S)
+
+def _local_to_utc(y,m,d,H,M,S,tz):
+    TZ = get_timezone(tz)
+    dt = datetime.datetime(y,m,d,H,M,int(S))
+    y,m,d,H,M,S = TZ.localize(dt).utctimetuple()[:6]
+    return (y,m,d,H,M,S)
 
 class Date:
     '''
@@ -43,7 +168,7 @@ class Date:
     "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. 
+    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
@@ -108,8 +233,9 @@ class Date:
         >>> d1-i1
         <Date 2003-07-01.00:00:0.000000>
     '''
-    
-    def __init__(self, spec='.', offset=0, add_granularity=0):
+
+    def __init__(self, spec='.', offset=0, add_granularity=False,
+            translator=i18n):
         """Construct a date given a specification and a time zone offset.
 
         'spec'
@@ -117,33 +243,37 @@ class Date:
            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 isinstance(spec, datetime.datetime):
+            # Python 2.3+ datetime object
+            y,m,d,H,M,S,x,x,x = spec.timetuple()
+            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
             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)
+                self.second = _local_to_utc(y, m, d, H, M, S, offset)
             # we lost the fractional part
             self.second = self.second + frac
+            if str(self.second) == '60.0': self.second = 59.9
         except:
-            raise ValueError, 'Unknown spec %r'%spec
-
-    usagespec='[yyyy]-[mm]-[dd].[H]H:MM[:SS.SSS][offset]'
-    def set(self, spec, offset=0, 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>.+)?                                     # offset
-            ''', re.VERBOSE), serialised_re=re.compile(r'''
-            (\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d(\.\d+)?)
-            ''', re.VERBOSE), add_granularity=0):
+            raise ValueError, 'Unknown spec %r' % (spec,)
+
+    def set(self, spec, offset=0, date_re=date_re,
+            serialised_re=serialised_date_re, add_granularity=False):
         ''' set the date to the value in spec
         '''
 
@@ -159,19 +289,33 @@ class Date:
         # not serialised data, try usual format
         m = date_re.match(spec)
         if m is None:
-            raise ValueError, _('Not a date spec: %s' % self.usagespec)
+            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()
 
+        # determine whether we need to add anything at the end
         if add_granularity:
-            _add_granularity(info, 'SMHdmyab')
+            for gran in 'SMHdmy':
+                if info[gran] is not None:
+                    if gran == 'S':
+                        raise ValueError
+                    elif gran == 'M':
+                        add_granularity = Interval('00:01')
+                    elif gran == 'H':
+                        add_granularity = Interval('01:00')
+                    else:
+                        add_granularity = Interval('+1%s'%gran)
+                    break
 
         # 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
+        dt = datetime.datetime.utcnow()
+        y,m,d,H,M,S,x,x,x = dt.timetuple()
+        S += dt.microsecond/1000000.
+
+        # whether we need to convert to UTC
+        adjust = False
 
         if info['y'] is not None or info['a'] is not None:
             if info['y'] is not None:
@@ -184,34 +328,43 @@ class Date:
             if info['a'] is not None:
                 m = int(info['a'])
                 d = int(info['b'])
-            H = -offset
+            H = 0
             M = S = 0
+            adjust = True
 
         # override hour, minute, second parts
         if info['H'] is not None and info['M'] is not None:
-            H = int(info['H']) - offset
+            H = int(info['H'])
             M = int(info['M'])
             S = 0
             if info['S'] is not None:
                 S = float(info['S'])
+            adjust = True
+
 
-        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))
+        dt = datetime.datetime(y,m,d,H,M,int(S), int(frac * 1000000.))
+        y, m, d, H, M, S, x, x, x = dt.timetuple()
+        if adjust:
+            y, m, d, H, M, S = _local_to_utc(y, m, d, H, M, S, offset)
         self.year, self.month, self.day, self.hour, self.minute, \
-            self.second, x, x, x = time.gmtime(ts)
+            self.second = y, m, d, H, M, S
         # we lost the fractional part along the way
-        self.second = self.second + frac
+        self.second += dt.microsecond/1000000.
 
         if info.get('o', None):
             try:
                 self.applyInterval(Interval(info['o'], allowdate=0))
             except ValueError:
-                raise ValueError, _('%r not a date spec (%s)')%(spec,
-                    self.usagespec)
+                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,)
+
+        # adjust by added granularity
+        if add_granularity:
+            self.applyInterval(add_granularity)
+            self.applyInterval(Interval('- 00:00:01'))
 
     def addInterval(self, interval):
         ''' Add the interval to this date, returning the date tuple
@@ -249,11 +402,11 @@ class Date:
 
         while month < 1 or month > 12 or day < 1 or day > get_mdays(year,month):
             # now to day under/over
-            if day < 1: 
+            if day < 1:
                 # When going backwards, decrement month, then increment days
                 month -= 1
                 day += get_mdays(year,month)
-            elif 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
@@ -278,7 +431,7 @@ class Date:
     def __add__(self, interval):
         """Add an interval to this date to produce another date.
         """
-        return Date(self.addInterval(interval))
+        return Date(self.addInterval(interval), translator=self.translator)
 
     # deviates from spec to allow subtraction of dates as well
     def __sub__(self, other):
@@ -316,7 +469,8 @@ class Date:
         M = (diff/60)%60
         H = (diff/(60*60))%24
         d = diff/(24*60*60)
-        return Interval((0, 0, d, H, M, S), sign=sign)
+        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."""
@@ -338,7 +492,7 @@ class Date:
         return self.formal()
 
     def formal(self, sep='.', sec='%02d'):
-        f = '%%4d-%%02d-%%02d%s%%02d:%%02d:%s'%(sep, sec)
+        f = '%%04d-%%02d-%%02d%s%%02d:%%02d:%s'%(sep, sec)
         return f%(self.year, self.month, self.day, self.hour, self.minute,
             self.second)
 
@@ -348,28 +502,36 @@ class Date:
             Note that if the day is zero, and the day appears first in the
             format, then the day number will be removed from output.
         '''
-        str = time.strftime(format, (self.year, self.month, self.day,
-            self.hour, self.minute, self.second, 0, 0, 0))
+        dt = datetime.datetime(self.year, self.month, self.day, self.hour,
+            self.minute, int(self.second),
+            int ((self.second - int (self.second)) * 1000000.))
+        str = dt.strftime(format)
+
         # 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='%f')
+        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))
+        y, m, d, H, M, S = _utc_to_local(self.year, self.month, self.day,
+                self.hour, self.minute, self.second, offset)
+        return Date((y, m, d, H, M, S, 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%02d'%(self.year, self.month,
+        return '%04d%02d%02d%02d%02d%06.3f'%(self.year, self.month,
             self.day, self.hour, self.minute, self.second)
 
     def timestamp(self):
@@ -380,6 +542,29 @@ class Date:
         # 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
+
+    def fromtimestamp(cls, ts):
+        """Create a date object from a timestamp.
+
+        The timestamp may be outside the gmtime year-range of
+        1902-2038.
+        """
+        usec = int((ts - int(ts)) * 1000000.)
+        delta = datetime.timedelta(seconds = int(ts), microseconds = usec)
+        return cls(datetime.datetime(1970, 1, 1) + delta)
+    fromtimestamp = classmethod(fromtimestamp)
+
 class Interval:
     '''
     Date intervals are specified using the suffixes "y", "m", and "d". The
@@ -432,12 +617,18 @@ class Interval:
 
     TODO: more examples, showing the order of addition operation
     '''
-    def __init__(self, spec, sign=1, allowdate=1, add_granularity=0):
+    def __init__(self, spec, sign=1, allowdate=1, add_granularity=False,
+        translator=i18n
+    ):
         """Construct an interval given a specification."""
-        if type(spec) in (IntType, FloatType, LongType):
+        self.setTranslator(translator)
+        if isinstance(spec, (int, float, long)):
             self.from_seconds(spec)
-        elif type(spec) in (StringType, UnicodeType):
+        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, \
@@ -450,6 +641,10 @@ class Interval:
                     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
@@ -464,7 +659,7 @@ class Interval:
                )?''', 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):
+            add_granularity=False):
         ''' set the date to the value in spec
         '''
         self.year = self.month = self.week = self.day = self.hour = \
@@ -474,15 +669,18 @@ class Interval:
         if not m:
             m = interval_re.match(spec)
             if not m:
-                raise ValueError, _('Not an interval spec: [+-] [#y] [#m] [#w] '
-                    '[#d] [[[H]H:MM]:SS] [date spec]')
+                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))
+            for gran in 'SMHdwmy':
+                if info[gran] is not None:
+                    info[gran] = int(info[gran]) + (info['s']=='-' and -1 or 1)
+                    break
 
         valid = 0
         for group, attr in {'y':'year', 'm':'month', 'w':'week', 'd':'day',
@@ -493,8 +691,8 @@ class Interval:
 
         # make sure it's valid
         if not valid and not info['D']:
-            raise ValueError, _('Not an interval spec: [+-] [#y] [#m] [#w] '
-                '[#d] [[[H]H:MM]:SS]')
+            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
@@ -543,17 +741,17 @@ class Interval:
     def __add__(self, other):
         if isinstance(other, Date):
             # the other is a Date - produce a Date
-            return Date(other.addInterval(self))
+            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]
+            asgn = a[0]
             b = other.get_tuple()
-            bs = b[0]
-            i = [as*x + bs*y for x,y in zip(a[1:],b[1:])]
+            bsgn = b[0]
+            i = [asgn*x + bsgn*y for x,y in zip(a[1:],b[1:])]
             i.insert(0, 1)
             i = fixTimeOverflow(i)
-            return Interval(i)
+            return Interval(i, translator=self.translator)
         # nope, no idea what to do with this other...
         raise TypeError, "Can't add %r"%other
 
@@ -562,17 +760,18 @@ class Interval:
             # the other is a Date - produce a Date
             interval = Interval(self.get_tuple())
             interval.sign *= -1
-            return Date(other.addInterval(interval))
+            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]
+            asgn = a[0]
             b = other.get_tuple()
-            bs = b[0]
-            i = [as*x - bs*y for x,y in zip(a[1:],b[1:])]
+            bsgn = b[0]
+            i = [asgn*x - bsgn*y for x,y in zip(a[1:],b[1:])]
             i.insert(0, 1)
             i = fixTimeOverflow(i)
-            return Interval(i)
+            return Interval(i, translator=self.translator)
         # nope, no idea what to do with this other...
         raise TypeError, "Can't add %r"%other
 
@@ -600,7 +799,8 @@ class Interval:
             sign = months<0 and -1 or 1
             m = months%12
             y = months / 12
-            return Interval((sign, y, m, 0, 0, 0, 0))
+            return Interval((sign, y, m, 0, 0, 0, 0),
+                translator=self.translator)
 
         else:
             # handle a day/time division
@@ -617,7 +817,8 @@ class Interval:
             seconds /= 60
             H = seconds%24
             d = seconds / 24
-            return Interval((sign, 0, 0, d, H, M, S))
+            return Interval((sign, 0, 0, d, H, M, S),
+                translator=self.translator)
 
     def __repr__(self):
         return '<Interval %s>'%self.__str__()
@@ -625,55 +826,61 @@ class Interval:
     def pretty(self):
         ''' print up the date date using one of these nice formats..
         '''
+        _quarters = self.minute / 15
         if self.year:
-            if self.year == 1:
-                s = _('1 year')
-            else:
-                s = _('%(number)s years')%{'number': self.year}
-        elif self.month or self.day > 13:
-            days = (self.month * 30) + self.day
-            if days > 28:
-                if int(days/30) > 1:
-                    s = _('%(number)s months')%{'number': int(days/30)}
-                else:
-                    s = _('1 month')
-            else:
-                s = _('%(number)s weeks')%{'number': int(days/7)}
+            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:
-            s = _('1 week')
+            _weeks = int(self.day / 7)
+            s = self.ngettext("%(number)s week", "%(number)s weeks",
+                _weeks) % {'number': _weeks}
         elif self.day > 1:
-            s = _('%(number)s days')%{'number': self.day}
+            # 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 _('tomorrow')
+                return self._('tomorrow')
             else:
-                return _('yesterday')
+                return self._('yesterday')
         elif self.hour > 1:
-            s = _('%(number)s hours')%{'number': self.hour}
+            # 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 = _('an hour')
-            elif self.minute/15 == 2:
-                s = _('1 1/2 hours')
+                s = self._('an hour')
+            elif _quarters == 2:
+                s = self._('1 1/2 hours')
             else:
-                s = _('1 %(number)s/4 hours')%{'number': self.minute/15}
+                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 _('in a moment')
+                return self._('in a moment')
             else:
-                return _('just now')
+                return self._('just now')
         elif self.minute == 1:
-            s = _('1 minute')
+            # Note: used in expressions "in 1 minute" or "1 minute ago"
+            s = self._('1 minute')
         elif self.minute < 15:
-            s = _('%(number)s minutes')%{'number': self.minute}
-        elif int(self.minute/15) == 2:
-            s = _('1/2 an hour')
+            # 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 = _('%(number)s/4 hour')%{'number': int(self.minute/15)}
-        if self.sign < 0: 
-            s = s + _(' ago')
+            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 = _('in ') + s
+            s = self._('in %s') % s
         return s
 
     def get_tuple(self):
@@ -687,7 +894,7 @@ class Interval:
 
     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.
         '''
@@ -706,6 +913,7 @@ class Interval:
         '''Figure my second, minute, hour and day values using a seconds
         value.
         '''
+        val = int(val)
         if val < 0:
             self.sign = -1
             val = -val
@@ -720,6 +928,18 @@ class Interval:
         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":
@@ -756,7 +976,7 @@ def fixTimeOverflow(time):
 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>]
@@ -774,46 +994,45 @@ class Range:
 
         >>> 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):
+    def __init__(self, spec, Type, allow_granularity=True, **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:
+        if ';' in spec:
             # Geek
-            mch_range = re.search(re_geek_range, spec.strip())
-        if mch_range:
-            self.from_value, self.to_value = mch_range.groups()
+            m = re.search(re_geek_range, spec.strip())
+        else:
+            # Native english
+            m = re.search(re_range, spec.strip(), re.IGNORECASE)
+        if m:
+            self.from_value, self.to_value = m.groups()
             if self.from_value:
                 self.from_value = Type(self.from_value.strip(), **params)
             if self.to_value:
@@ -821,7 +1040,7 @@ class Range:
         else:
             if allow_granularity:
                 self.from_value = Type(spec, **params)
-                self.to_value = Type(spec, add_granularity=1, **params)
+                self.to_value = Type(spec, add_granularity=True, **params)
             else:
                 raise ValueError, "Invalid range"
 
@@ -830,7 +1049,7 @@ class Range:
 
     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')
@@ -864,4 +1083,4 @@ def test():
 if __name__ == '__main__':
     test()
 
-# vim: set filetype=python ts=4 sw=4 et si
+# vim: set filetype=python sts=4 sw=4 et si :
index 4bac87fc63039f2f258e2a26737990063e101f3b..637543c59405b4a68e02c9b70dc6fee9c8f8574e 100644 (file)
 # 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.96 2004-02-11 23:55:08 richard Exp $
+#
+# $Id: hyperdb.py,v 1.132 2008-08-18 06:21:53 richard Exp $
 
 """Hyperdatabase implementation, especially field types.
 """
 __docformat__ = 'restructuredtext'
 
 # standard python modules
-import sys, os, time, re
+import os, re, shutil, weakref
+from sets import Set
 
 # roundup modules
 import date, password
-
-# configure up the DEBUG and TRACE captures
-class Sink:
-    def write(self, content):
-        pass
-DEBUG = os.environ.get('HYPERDBDEBUG', '')
-if DEBUG and __debug__:
-    if DEBUG == 'stdout':
-        DEBUG = sys.stdout
-    else:
-        DEBUG = open(DEBUG, 'a')
-else:
-    DEBUG = Sink()
-TRACE = os.environ.get('HYPERDBTRACE', '')
-if TRACE and __debug__:
-    if TRACE == 'stdout':
-        TRACE = sys.stdout
-    else:
-        TRACE = open(TRACE, 'w')
-else:
-    TRACE = Sink()
-def traceMark():
-    print >>TRACE, '**MARK', time.ctime()
-del Sink
+from support import ensureParentsExist, PrioList, sorted, reversed
+from roundup.i18n import _
 
 #
 # Types
 #
-class String:
-    """An object designating a String property."""
-    def __init__(self, indexme='no'):
-        self.indexme = indexme == 'yes'
+class _Type(object):
+    """A roundup property type."""
+    def __init__(self, required=False):
+        self.required = required
     def __repr__(self):
         ' more useful for dumps '
-        return '<%s>'%self.__class__
+        return '<%s.%s>'%(self.__class__.__module__, self.__class__.__name__)
+    def sort_repr (self, cls, val, name):
+        """Representation used for sorting. This should be a python
+        built-in type, otherwise sorting will take ages. Note that
+        individual backends may chose to use something different for
+        sorting as long as the outcome is the same.
+        """
+        return val
 
-class Password:
+class String(_Type):
+    """An object designating a String property."""
+    def __init__(self, indexme='no', required=False):
+        super(String, self).__init__(required)
+        self.indexme = indexme == 'yes'
+    def from_raw(self, value, propname='', **kw):
+        """fix the CRLF/CR -> LF stuff"""
+        if propname == 'content':
+            # Why oh why wasn't the FileClass content property a File
+            # type from the beginning?
+            return value
+        return fixNewlines(value)
+    def sort_repr (self, cls, val, name):
+        if not val:
+            return val
+        if name == 'id':
+            return int(val)
+        return val.lower()
+
+class Password(_Type):
     """An object designating a Password property."""
-    def __repr__(self):
-        ' more useful for dumps '
-        return '<%s>'%self.__class__
-
-class Date:
+    def from_raw(self, value, **kw):
+        if not value:
+            return None
+        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')%(kw['propname'], message)
+        return value
+    def sort_repr (self, cls, val, name):
+        if not val:
+            return val
+        return str(val)
+
+class Date(_Type):
     """An object designating a Date property."""
-    def __repr__(self):
-        ' more useful for dumps '
-        return '<%s>'%self.__class__
-
-class Interval:
+    def __init__(self, offset=None, required=False):
+        super(Date, self).__init__(required)
+        self._offset = offset
+    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, 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))
+    def sort_repr (self, cls, val, name):
+        if not val:
+            return val
+        return str(val)
+
+class Interval(_Type):
     """An object designating an Interval property."""
-    def __repr__(self):
-        ' more useful for dumps '
-        return '<%s>'%self.__class__
-
-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
+    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
+    def sort_repr (self, cls, val, name):
+        if not val:
+            return val
+        return val.as_seconds()
+
+class _Pointer(_Type):
+    """An object designating a Pointer property that links or multilinks
+    to a node in a specified class."""
+    def __init__(self, classname, do_journal='yes', required=False):
+        ''' Default is to journal link and unlink events
         '''
+        super(_Pointer, self).__init__(required)
         self.classname = classname
         self.do_journal = do_journal == 'yes'
     def __repr__(self):
-        ' more useful for dumps '
-        return '<%s to "%s">'%(self.__class__, self.classname)
+        """more useful for dumps. But beware: This is also used in schema
+        storage in SQL backends!
+        """
+        return '<%s.%s to "%s">'%(self.__class__.__module__,
+            self.__class__.__name__, self.classname)
 
-class Multilink:
+class Link(_Pointer):
+    """An object designating a Link property that links to a
+       node in a specified class."""
+    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
+    def sort_repr (self, cls, val, name):
+        if not val:
+            return val
+        op = cls.labelprop()
+        if op == 'id':
+            return int(cls.get(val, op))
+        return cls.get(val, op)
+
+class Multilink(_Pointer):
     """An object designating a Multilink property that links
        to nodes in a specified class.
 
@@ -101,26 +175,98 @@ class Multilink:
        "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):
+        if not value:
+            return []
+
+        # 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(',')
 
-class Boolean:
+        # 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
+
+    def sort_repr (self, cls, val, name):
+        if not val:
+            return val
+        op = cls.labelprop()
+        if op == 'id':
+            return [int(cls.get(v, op)) for v in val]
+        return [cls.get(v, op) for v in val]
+
+class Boolean(_Type):
     """An object designating a boolean property"""
-    def __repr__(self):
-        'more useful for dumps'
-        return '<%s>' % self.__class__
-    
-class Number:
+    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(_Type):
     """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
 #
@@ -131,9 +277,290 @@ def splitDesignator(designator, dre=re.compile(r'([^\d]+)(\d+)')):
     '''
     m = dre.match(designator)
     if m is None:
-        raise DesignatorError, '"%s" not a node designator'%designator
+        raise DesignatorError, _('"%s" not a node designator')%designator
     return m.group(1), m.group(2)
 
+class Proptree(object):
+    ''' Simple tree data structure for optimizing searching of
+    properties. Each node in the tree represents a roundup Class
+    Property that has to be navigated for finding the given search
+    or sort properties. The sort_type attribute is used for
+    distinguishing nodes in the tree used for sorting or searching: If
+    it is 0 for a node, that node is not used for sorting. If it is 1,
+    it is used for both, sorting and searching. If it is 2 it is used
+    for sorting only.
+
+    The Proptree is also used for transitively searching attributes for
+    backends that do not support transitive search (e.g. anydbm). The
+    _val attribute with set_val is used for this.
+    '''
+
+    def __init__(self, db, cls, name, props, parent = None):
+        self.db = db
+        self.name = name
+        self.props = props
+        self.parent = parent
+        self._val = None
+        self.has_values = False
+        self.cls = cls
+        self.classname = None
+        self.uniqname = None
+        self.children = []
+        self.sortattr = []
+        self.propdict = {}
+        self.sort_type = 0
+        self.sort_direction = None
+        self.sort_ids = None
+        self.sort_ids_needed = False
+        self.sort_result = None
+        self.attr_sort_done = False
+        self.tree_sort_done = False
+        self.propclass = None
+        self.orderby = []
+        if parent:
+            self.root = parent.root
+            self.depth = parent.depth + 1
+        else:
+            self.root = self
+            self.seqno = 1
+            self.depth = 0
+            self.sort_type = 1
+        self.id = self.root.seqno
+        self.root.seqno += 1
+        if self.cls:
+            self.classname = self.cls.classname
+            self.uniqname = '%s%s' % (self.cls.classname, self.id)
+        if not self.parent:
+            self.uniqname = self.cls.classname
+
+    def append(self, name, sort_type = 0):
+        """Append a property to self.children. Will create a new
+        propclass for the child.
+        """
+        if name in self.propdict:
+            pt = self.propdict[name]
+            if sort_type and not pt.sort_type:
+                pt.sort_type = 1
+            return pt
+        propclass = self.props[name]
+        cls = None
+        props = None
+        if isinstance(propclass, (Link, Multilink)):
+            cls = self.db.getclass(propclass.classname)
+            props = cls.getprops()
+        child = self.__class__(self.db, cls, name, props, parent = self)
+        child.sort_type = sort_type
+        child.propclass = propclass
+        self.children.append(child)
+        self.propdict[name] = child
+        return child
+
+    def compute_sort_done(self, mlseen=False):
+        """ Recursively check if attribute is needed for sorting
+        (self.sort_type > 0) or all children have tree_sort_done set and
+        sort_ids_needed unset: set self.tree_sort_done if one of the conditions
+        holds. Also remove sort_ids_needed recursively once having seen a
+        Multilink.
+        """
+        if isinstance (self.propclass, Multilink):
+            mlseen = True
+        if mlseen:
+            self.sort_ids_needed = False
+        self.tree_sort_done = True
+        for p in self.children:
+            p.compute_sort_done(mlseen)
+            if not p.tree_sort_done:
+                self.tree_sort_done = False
+        if not self.sort_type:
+            self.tree_sort_done = True
+        if mlseen:
+            self.tree_sort_done = False
+
+    def ancestors(self):
+        p = self
+        while p.parent:
+            yield p
+            p = p.parent
+
+    def search(self, search_matches=None, sort=True):
+        """ Recursively search for the given properties in a proptree.
+        Once all properties are non-transitive, the search generates a
+        simple _filter call which does the real work
+        """
+        filterspec = {}
+        for p in self.children:
+            if p.sort_type < 2:
+                if p.children:
+                    p.search(sort = False)
+                filterspec[p.name] = p.val
+        self.val = self.cls._filter(search_matches, filterspec, sort and self)
+        return self.val
+
+    def sort (self, ids=None):
+        """ Sort ids by the order information stored in self. With
+        optimisations: Some order attributes may be precomputed (by the
+        backend) and some properties may already be sorted.
+        """
+        if ids is None:
+            ids = self.val
+        if self.sortattr and [s for s in self.sortattr if not s.attr_sort_done]:
+            return self._searchsort(ids, True, True)
+        return ids
+
+    def sortable_children(self, intermediate=False):
+        """ All children needed for sorting. If intermediate is True,
+        intermediate nodes (not being a sort attribute) are returned,
+        too.
+        """
+        return [p for p in self.children
+                if p.sort_type > 0 and (intermediate or p.sort_direction)]
+
+    def __iter__(self):
+        """ Yield nodes in depth-first order -- visited nodes first """
+        for p in self.children:
+            yield p
+            for c in p:
+                yield c
+
+    def _get (self, ids):
+        """Lookup given ids -- possibly a list of list. We recurse until
+        we have a list of ids.
+        """
+        if not ids:
+            return ids
+        if isinstance (ids[0], list):
+            cids = [self._get(i) for i in ids]
+        else:
+            cids = [i and self.parent.cls.get(i, self.name) for i in ids]
+            if self.sortattr:
+                cids = [self._searchsort(i, False, True) for i in cids]
+        return cids
+
+    def _searchsort(self, ids=None, update=True, dosort=True):
+        """ Recursively compute the sort attributes. Note that ids
+        may be a deeply nested list of lists of ids if several
+        multilinks are encountered on the way from the root to an
+        individual attribute. We make sure that everything is properly
+        sorted on the way up. Note that the individual backend may
+        already have precomputed self.result or self.sort_ids. In this
+        case we do nothing for existing sa.result and recurse further if
+        self.sort_ids is available.
+
+        Yech, Multilinks: This gets especially complicated if somebody
+        sorts by different attributes of the same multilink (or
+        transitively across several multilinks). My use-case is sorting
+        by issue.messages.author and (reverse) by issue.messages.date.
+        In this case we sort the messages by author and date and use
+        this sorted list twice for sorting issues. This means that
+        issues are sorted by author and then by the time of the messages
+        *of this author*. Probably what the user intends in that case,
+        so we do *not* use two sorted lists of messages, one sorted by
+        author and one sorted by date for sorting issues.
+        """
+        for pt in self.sortable_children(intermediate = True):
+            # ids can be an empty list
+            if pt.tree_sort_done or not ids:
+                continue
+            if pt.sort_ids: # cached or computed by backend
+                cids = pt.sort_ids
+            else:
+                cids = pt._get(ids)
+            if pt.sort_direction and not pt.sort_result:
+                sortrep = pt.propclass.sort_repr
+                pt.sort_result = pt._sort_repr(sortrep, cids)
+            pt.sort_ids = cids
+            if pt.children:
+                pt._searchsort(cids, update, False)
+        if self.sortattr and dosort:
+            ids = self._sort(ids)
+        if not update:
+            for pt in self.sortable_children(intermediate = True):
+                pt.sort_ids = None
+            for pt in self.sortattr:
+                pt.sort_result = None
+        return ids
+
+    def _set_val(self, val):
+        """Check if self._val is already defined. If yes, we compute the
+        intersection of the old and the new value(s)
+        """
+        if self.has_values:
+            v = self._val
+            if not isinstance(self._val, type([])):
+                v = [self._val]
+            vals = Set(v)
+            vals.intersection_update(val)
+            self._val = [v for v in vals]
+        else:
+            self._val = val
+        self.has_values = True
+
+    val = property(lambda self: self._val, _set_val)
+
+    def _sort(self, val):
+        """Finally sort by the given sortattr.sort_result. Note that we
+        do not sort by attrs having attr_sort_done set. The caller is
+        responsible for setting attr_sort_done only for trailing
+        attributes (otherwise the sort order is wrong). Since pythons
+        sort is stable, we can sort already sorted lists without
+        destroying the sort-order for items that compare equal with the
+        current sort.
+
+        Sorting-Strategy: We sort repeatedly by different sort-keys from
+        right to left. Since pythons sort is stable, we can safely do
+        that. An optimisation is a "run-length encoding" of the
+        sort-directions: If several sort attributes sort in the same
+        direction we can combine them into a single sort. Note that
+        repeated sorting is probably more efficient than using
+        compare-methods in python due to the overhead added by compare
+        methods.
+        """
+        if not val:
+            return val
+        sortattr = []
+        directions = []
+        dir_idx = []
+        idx = 0
+        curdir = None
+        for sa in self.sortattr:
+            if sa.attr_sort_done:
+                break
+            if sortattr:
+                assert len(sortattr[0]) == len(sa.sort_result)
+            sortattr.append (sa.sort_result)
+            if curdir != sa.sort_direction:
+                dir_idx.append (idx)
+                directions.append (sa.sort_direction)
+                curdir = sa.sort_direction
+            idx += 1
+        sortattr.append (val)
+        #print >> sys.stderr, "\nsortattr", sortattr
+        sortattr = zip (*sortattr)
+        for dir, i in reversed(zip(directions, dir_idx)):
+            rev = dir == '-'
+            sortattr = sorted (sortattr, key = lambda x:x[i:idx], reverse = rev)
+            idx = i
+        return [x[-1] for x in sortattr]
+
+    def _sort_repr(self, sortrep, ids):
+        """Call sortrep for given ids -- possibly a list of list. We
+        recurse until we have a list of ids.
+        """
+        if not ids:
+            return ids
+        if isinstance (ids[0], list):
+            res = [self._sort_repr(sortrep, i) for i in ids]
+        else:
+            res = [sortrep(self.cls, i, self.name) for i in ids]
+        return res
+
+    def __repr__(self):
+        r = ["proptree:" + self.name]
+        for n in self:
+            r.append("proptree:" + "    " * n.depth + n.name)
+        return '\n'.join(r)
+    __str__ = __repr__
+
 #
 # the base Database class
 #
@@ -170,6 +597,10 @@ 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.
 
@@ -187,7 +618,7 @@ All methods except __repr__ must be implemented by a concrete backend Database.
         raise NotImplementedError
 
     def post_init(self):
-        """Called once the schema initialisation has finished. 
+        """Called once the schema initialisation has finished.
            If 'refresh' is true, we want to rebuild the backend
            structures.
         """
@@ -269,7 +700,7 @@ All methods except __repr__ must be implemented by a concrete backend Database.
 
     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.
         '''
@@ -305,6 +736,12 @@ All methods except __repr__ must be implemented by a concrete backend Database.
 
         Save all data changed since the database was opened or since the
         last commit() or rollback().
+
+        fail_ok indicates that the commit is allowed to fail. This is used
+        in the web interface when committing cleaning of the session
+        database. We don't care if there's a concurrency issue there.
+
+        The only backend this seems to affect is postgres.
         '''
         raise NotImplementedError
 
@@ -316,12 +753,19 @@ All methods except __repr__ must be implemented by a concrete backend Database.
         '''
         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.
     """
@@ -333,7 +777,25 @@ class Class:
         or a ValueError is raised.  The keyword arguments in 'properties'
         must map names to property objects, or a TypeError is raised.
         """
-        raise NotImplementedError
+        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
@@ -349,13 +811,13 @@ class Class:
 
         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.
         """
@@ -391,7 +853,7 @@ class Class:
 
     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.
 
@@ -411,10 +873,10 @@ class Class:
 
     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.
         """
@@ -426,7 +888,7 @@ class Class:
         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
         '''
@@ -434,7 +896,7 @@ class Class:
 
     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
@@ -483,6 +945,22 @@ class Class:
         """
         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
@@ -493,12 +971,45 @@ class Class:
         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
         """
-        raise NotImplementedError
+        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.
@@ -513,7 +1024,7 @@ class Class:
     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,}   
+        '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.
@@ -527,24 +1038,123 @@ class Class:
         """
         raise NotImplementedError
 
-    def filter(self, search_matches, filterspec, sort=(None,None),
+    def _filter(self, search_matches, filterspec, sort=(None,None),
             group=(None,None)):
+        """For some backends this implements the non-transitive
+        search, for more information see the filter method.
+        """
+        raise NotImplementedError
+
+    def _proptree(self, filterspec, sortattr=[]):
+        """Build a tree of all transitive properties in the given
+        filterspec.
+        """
+        proptree = Proptree(self.db, self, '', self.getprops())
+        for key, v in filterspec.iteritems():
+            keys = key.split('.')
+            p = proptree
+            for k in keys:
+                p = p.append(k)
+            p.val = v
+        multilinks = {}
+        for s in sortattr:
+            keys = s[1].split('.')
+            p = proptree
+            for k in keys:
+                p = p.append(k, sort_type = 2)
+                if isinstance (p.propclass, Multilink):
+                    multilinks[p] = True
+            if p.cls:
+                p = p.append(p.cls.orderprop(), sort_type = 2)
+            if p.sort_direction: # if an orderprop is also specified explicitly
+                continue
+            p.sort_direction = s[0]
+            proptree.sortattr.append (p)
+        for p in multilinks.iterkeys():
+            sattr = {}
+            for c in p:
+                if c.sort_direction:
+                    sattr [c] = True
+            for sa in proptree.sortattr:
+                if sa in sattr:
+                    p.sortattr.append (sa)
+        return proptree
+
+    def get_transitive_prop(self, propname_path, default = None):
+        """Expand a transitive property (individual property names
+        separated by '.' into a new property at the end of the path. If
+        one of the names does not refer to a valid property, we return
+        None.
+        Example propname_path (for class issue): "messages.author"
+        """
+        props = self.db.getclass(self.classname).getprops()
+        for k in propname_path.split('.'):
+            try:
+                prop = props[k]
+            except KeyError, TypeError:
+                return default
+            cl = getattr(prop, 'classname', None)
+            props = None
+            if cl:
+                props = self.db.getclass(cl).getprops()
+        return prop
+
+    def _sortattr(self, sort=[], group=[]):
+        """Build a single list of sort attributes in the correct order
+        with sanity checks (no duplicate properties) included. Always
+        sort last by id -- if id is not already in sortattr.
+        """
+        seen = {}
+        sortattr = []
+        for srt in group, sort:
+            if not isinstance(srt, list):
+                srt = [srt]
+            for s in srt:
+                if s[1] and s[1] not in seen:
+                    sortattr.append((s[0] or '+', s[1]))
+                    seen[s[1]] = True
+        if 'id' not in seen :
+            sortattr.append(('+', 'id'))
+        return sortattr
+
+    def filter(self, search_matches, filterspec, sort=[], group=[]):
         """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
+        "sort" and "group" are [(dir, prop), ...] where dir is '+', '-'
+        or None and prop is a prop name or None. Note that for
+        backward-compatibility reasons a single (dir, prop) tuple is
+        also allowed.
 
         "search_matches" is {nodeid: marker}
 
-        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.
+        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.
+
+        Note that now the propname in filterspec and prop in a
+        sort/group spec may be transitive, i.e., it may contain
+        properties of the form link.link.link.name, e.g. you can search
+        for all issues where a message was added by a certain user in
+        the last week with a filterspec of
+        {'messages.author' : '42', 'messages.creation' : '.-1w;'}
+
+        Implementation note:
+        This implements a non-optimized version of Transitive search
+        using _filter implemented in a backend class. A more efficient
+        version can be implemented in the individual backends -- e.g.,
+        an SQL backen will want to create a single SQL statement and
+        override the filter method instead of implementing _filter.
         """
-        raise NotImplementedError
+        sortattr = self._sortattr(sort = sort, group = group)
+        proptree = self._proptree(filterspec, sortattr)
+        proptree.search(search_matches)
+        return proptree.sort()
 
     def count(self):
         """Get the number of nodes in this class.
@@ -563,6 +1173,17 @@ class Class:
         """
         raise NotImplementedError
 
+    def get_required_props(self, propnames = []):
+        """Return a dict of property names mapping to property objects.
+        All properties that have the "required" flag set will be
+        returned in addition to all properties in the propnames
+        parameter.
+        """
+        props = self.getprops(protected = False)
+        pdict = dict([(p, props[p]) for p in propnames])
+        pdict.update([(k, v) for k, v in props.iteritems() if v.required])
+        return pdict
+
     def addprop(self, **properties):
         """Add properties to this class.
 
@@ -574,25 +1195,45 @@ class Class:
         raise NotImplementedError
 
     def index(self, nodeid):
-        '''Add (or refresh) the node to search indexes
-        '''
+        """Add (or refresh) the node to search indexes"""
         raise NotImplementedError
 
-    def safeget(self, nodeid, propname, default=None):
-        """Safely get the value of a property on an existing node of this class.
+    #
+    # Detector interface
+    #
+    def audit(self, event, detector, priority = 100):
+        """Register an auditor detector"""
+        self.auditors[event].append((priority, detector.__name__, detector))
+
+    def fireAuditors(self, event, nodeid, newvalues):
+        """Fire all registered auditors"""
+        for prio, name, 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.__name__, detector))
+
+    def fireReactors(self, event, nodeid, oldvalues):
+        """Fire all registered reactors"""
+        for prio, name, 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
 
-        Return 'default' if the node doesn't exist.
-        """
-        try:
-            return self.get(nodeid, propname)
-        except IndexError:
-            return default            
 
 class HyperdbValueError(ValueError):
     ''' Error converting a raw value into a Hyperdb value '''
     pass
 
-def convertLinkValue(db, propname, prop, value, idre=re.compile('\d+')):
+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):
@@ -600,11 +1241,11 @@ def convertLinkValue(db, propname, prop, value, idre=re.compile('\d+')):
             try:
                 value = linkcl.lookup(value)
             except KeyError, message:
-                raise HyperdbValueError, 'property %s: %r is not a %s.'%(
+                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
+            raise HyperdbValueError, _('you may only enter ID values '\
+                'for property %s')%propname
     return value
 
 def fixNewlines(text):
@@ -617,8 +1258,7 @@ def fixNewlines(text):
     text = text.replace('\r\n', '\n')
     return text.replace('\r', '\n')
 
-def rawToHyperdb(db, klass, itemid, propname, value,
-        pwre=re.compile(r'{(\w+)}(.+)')):
+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".
@@ -634,7 +1274,7 @@ def rawToHyperdb(db, klass, itemid, propname, value,
     try:
         proptype =  properties[propname]
     except KeyError:
-        raise HyperdbValueError, '%r is not a property of %s'%(propname,
+        raise HyperdbValueError, _('%r is not a property of %s')%(propname,
             klass.classname)
 
     # if we got a string, strip it now
@@ -642,122 +1282,63 @@ def rawToHyperdb(db, klass, itemid, propname, value,
         value = value.strip()
 
     # convert the input value to a real property value
-    if isinstance(proptype, String):
-        # fix the CRLF/CR -> LF stuff
-        value = fixNewlines(value)
-    if isinstance(proptype, Password):
-        m = 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'%(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)
-    elif isinstance(proptype, Date):
-        try:
-            tz = db.getUserTimezone()
-            value = date.Date(value).local(tz)
-        except ValueError, message:
-            raise HyperdbValueError, 'property %s: %r is an invalid '\
-                'date (%s)'%(propname, value, message)
-    elif isinstance(proptype, Interval):
-        try:
-            value = date.Interval(value)
-        except ValueError, message:
-            raise HyperdbValueError, 'property %s: %r is an invalid '\
-                'date interval (%s)'%(propname, value, message)
-    elif isinstance(proptype, Link):
-        if value == '-1' or not value:
-            value = None
-        else:
-            value = convertLinkValue(db, propname, proptype, value)
-
-    elif isinstance(proptype, Multilink):
-        # 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 = []
+    value = proptype.from_raw(value, db=db, klass=klass,
+        propname=propname, itemid=itemid, **kw)
 
-        # if the value is a comma-separated string then split it now
-        if isinstance(value, type('')):
-            value = value.split(',')
+    return value
 
-        # 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()
+class FileClass:
+    ''' A class that requires the "content" property and stores it on
+        disk.
+    '''
+    default_mime_type = 'text/plain'
 
-            # skip blanks
-            if not item: continue
+    def __init__(self, db, classname, **properties):
+        '''The newly-created class automatically includes the "content"
+        property.
+        '''
+        if not properties.has_key('content'):
+            properties['content'] = String(indexme='yes')
 
-            # handle +/-
-            remove = 0
-            if item.startswith('-'):
-                remove = 1
-                item = item[1:]
-                set = 0
-            elif item.startswith('+'):
-                item = item[1:]
-                set = 0
+    def export_propnames(self):
+        ''' Don't export the "content" property
+        '''
+        propnames = self.getprops().keys()
+        propnames.remove('content')
+        propnames.sort()
+        return propnames
 
-            # look up the value
-            itemid = convertLinkValue(db, propname, proptype, item)
+    def exportFilename(self, dirname, nodeid):
+        subdir_filename = self.db.subdirFilename(self.classname, nodeid)
+        return os.path.join(dirname, self.classname+'-files', subdir_filename)
 
-            # 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)
+    def export_files(self, dirname, nodeid):
+        ''' Export the "content" property as a file, not csv column
+        '''
+        source = self.db.filename(self.classname, nodeid)
 
-        # that's it, set the new Multilink property value,
-        # or overwrite it completely
-        if set:
-            value = newvalue
-        else:
-            value = curvalue
+        dest = self.exportFilename(dirname, nodeid)
+        ensureParentsExist(dest)
+        shutil.copyfile(source, dest)
 
-        # 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]
-    elif isinstance(proptype, Boolean):
-        value = value.strip()
-        value = value.lower() in ('yes', 'true', 'on', '1')
-    elif isinstance(proptype, Number):
-        value = value.strip()
-        try:
-            value = float(value)
-        except ValueError:
-            raise HyperdbValueError, 'property %s: %r is not a number'%(
-                propname, value)
-    return value
-
-class FileClass:
-    ''' A class that requires the "content" property and stores it on
-        disk.
-    '''
-    pass
+    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
+        props = self.getprops()
+        if props.has_key('type'):
+            mime_type = self.get(nodeid, 'type')
+        if not mime_type:
+            mime_type = self.default_mime_type
+        if props['content'].indexme:
+            self.db.indexer.add_text((self.classname, nodeid, 'content'),
+                self.get(nodeid, 'content'), mime_type)
 
 class Node:
     ''' A convenience wrapper for the given node
@@ -779,7 +1360,7 @@ class Node:
         return l
     def has_key(self, name):
         return self.cl.getprops().has_key(name)
-    def get(self, name, default=None): 
+    def get(self, name, default=None):
         if self.has_key(name):
             return self[name]
         else:
@@ -816,6 +1397,6 @@ def Choice(name, db, *options):
     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)
+    return Link(name)
 
-# vim: set filetype=python ts=4 sw=4 et si
+# vim: set filetype=python sts=4 sw=4 et si :
index 378d593e58cf20c7925b3cc102eb5b809aa47dc5..ecebc9ece8f18edfe82c49caa03df1ed3e359858 100644 (file)
@@ -14,8 +14,8 @@
 # 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.4 2004-02-11 23:55:08 richard Exp $
+#
+# $Id: i18n.py,v 1.15 2005-06-14 05:33:32 a1s Exp $
 
 """
 RoundUp Internationalization (I18N)
@@ -37,15 +37,193 @@ the dynamic portion of a message really means.
 """
 __docformat__ = 'restructuredtext'
 
-# first, we try to import gettext; this probably never fails, but we make
-# sure we survive this anyway
-try:
-    import gettext
-except ImportError:
-    # fall-back to dummy on errors (returning the english text)
-    _ = lambda text: text
+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:
-    # and for now, we JUST implement the dummy in any case
-    _ = lambda text: text
+    # 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 ts=4 sw=4 et si
+# vim: set filetype=python sts=4 sw=4 et si :
index b334c087faf3d7e1a80e2ca713cc62019a14f799..8506f22aefee3dbc20dfd435d5391e1a93dd2a7b 100644 (file)
 # 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.29 2004-02-11 23:55:08 richard Exp $
+#
+# $Id: init.py,v 1.36 2005-12-03 11:22:50 a1s Exp $
 
 """Init (create) a roundup instance.
 """
 __docformat__ = 'restructuredtext'
 
-import os, sys, errno, rfc822
+import os, errno, rfc822
 
-import roundup.instance, password
-from roundup import install_util
+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().
@@ -54,35 +55,39 @@ def copytree(src, dst, symlinks=0):
         else:
             install_util.copyDigestedFile(srcname, dstname)
 
-def install(instance_home, template):
+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 standard instance_home
+    the named template (roundup.templates.<name>). A usual instance_home
     contains:
 
-    config.py
-      simple configuration of things like the email address for the
-      mail gateway, the mail domain, the mail host, ...
-    dbinit.py and select_db.py
-      defines the schema for the hyperdatabase and indicates which
-      backend to use.
+    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
-      defines the CGI Client and mail gateway MailGW classes that are
+      (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.
-    __init__.py
-      ties together all the instance information into one interface
     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)
@@ -92,6 +97,13 @@ def install(instance_home, template):
     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.
@@ -118,6 +130,12 @@ def loadTemplateInfo(dir):
     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:
@@ -159,20 +177,13 @@ def saveTemplateInfo(dir, info):
 def write_select_db(instance_home, backend):
     ''' Write the file that selects the backend for the tracker
     '''
-    # now select database
-    db = '''# WARNING: DO NOT EDIT THIS FILE!!!
-from roundup.backends.back_%s import Database, Class, FileClass, IssueClass
-'''%backend
-    open(os.path.join(instance_home, 'select_db.py'), 'w').write(db)
-
+    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()
 
-def initialise(instance_home, adminpw):
-    '''Initialise an instance's database
 
-    adminpw    - the password for the "admin" user
-    '''
-    # now import the instance and call its init
-    instance = roundup.instance.open(instance_home)
-    instance.init(password.Password(adminpw))
 
-# vim: set filetype=python ts=4 sw=4 et si
+# vim: set filetype=python sts=4 sw=4 et si :
index 97c16f410ba78c34270424e467c2a66661a0dd26..2e59a5baadee9713e572a408f5e755ba62938245 100644 (file)
@@ -15,7 +15,7 @@
 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 # 
-# $Id: install_util.py,v 1.10 2004-02-11 23:55:08 richard Exp $
+# $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.
 """
@@ -23,9 +23,8 @@ __docformat__ = 'restructuredtext'
 
 import os, sha, shutil
 
-# ".filter", ".index", ".item", ".newitem" are roundup-specific
-sgml_file_types = [".xml", ".ent", ".html", ".filter", ".index", ".item", ".newitem"]
-hash_file_types = [".py", ".sh", ".conf", ".cgi", '']
+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
index 81c946167c6e7a8c0c6815ff2f10a35d288aaf0c..9aba2d053aa1138d13bd1bc655d393ca6e3a0a37 100644 (file)
@@ -14,8 +14,8 @@
 # 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.12 2004-02-11 23:55:08 richard Exp $
+#
+# $Id: instance.py,v 1.37 2006-12-11 23:36:15 richard Exp $
 
 '''Tracker handling (open tracker).
 
@@ -24,31 +24,168 @@ Backwards compatibility for the old-style "imported" trackers.
 __docformat__ = 'restructuredtext'
 
 import os
+import sys
+from roundup import configuration, mailgw
+from roundup import hyperdb, backends
+from roundup.cgi import client, templating
 
 class Vars:
-    ''' I'm just a container '''
+    def __init__(self, vars):
+        self.__dict__.update(vars)
 
 class Tracker:
-    def __init__(self, tracker_home):
+    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.select_db = self._load_python('select_db.py')
-        self.config = self._load_config('config.py')
-        raise NotImplemented, 'this is *so* not finished'
-        self.init =  XXX
-        self.Client = XXX
-        self.MailGW = XXX
-
-    def open(self):
-        return self._load_config('schema.py').db
-        self._load_config('security.py', db=db)
-
-
-    def _load_python(self, file):
-        file = os.path.join(tracker_home, file)
-        vars = Vars()
-        execfile(file, vars.__dict__)
+        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:
+            libdir = os.path.join(self.tracker_home, 'lib')
+            if os.path.isdir(libdir):
+                sys.path.insert(1, libdir)
+            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
+            if libdir in sys.path:
+                sys.path.remove(libdir)
+
+    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:
+            libdir = os.path.join(self.tracker_home, 'lib')
+            if os.path.isdir(libdir):
+                sys.path.insert(1, libdir)
+            # 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')
+            if libdir in sys.path:
+                sys.path.remove(libdir)
+        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):
+            sys.path.insert(1, 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'])
+            sys.path.remove(dirpath)
+        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
@@ -59,11 +196,18 @@ class OldStyleTrackers:
         self.number = 0
         self.trackers = {}
 
-    def open(self, tracker_home):
-        ''' Open the tracker.
+    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.
 
-            Raise ValueError if the tracker home doesn't exist.
-        '''
+        """
         import imp
         # sanity check existence of tracker home
         if not os.path.exists(tracker_home):
@@ -78,6 +222,8 @@ class OldStyleTrackers:
         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
@@ -86,19 +232,28 @@ class OldStyleTrackers:
         tracker = imp.load_package(modname, tracker_home)
 
         # ensure the tracker has all the required bits
-        for required in 'config open init Client MailGW'.split():
+        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):
+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)
+        return OldStyleTrackers.open(tracker_home, optimize=optimize)
 
-    return Tracker(tracker_home)
+    return Tracker(tracker_home, optimize=optimize)
 
-# vim: set filetype=python ts=4 sw=4 et si
+# vim: set filetype=python sts=4 sw=4 et si :
index 0ddabe974a24bf2bf3b85fe0e55a42a0653af5db..09f808ca4dfddb0e82a8199e69ec1d26f8a6e946 100644 (file)
@@ -1,15 +1,22 @@
 """Sending Roundup-specific mail over SMTP.
 """
 __docformat__ = 'restructuredtext'
-# $Id: mailer.py,v 1.9 2004-03-25 22:53:26 richard Exp $
+# $Id: mailer.py,v 1.22 2008-07-21 01:44:58 richard Exp $
 
-import time, quopri, os, socket, smtplib, re
+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__
+from roundup.date import get_timezone
+
+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
@@ -21,7 +28,18 @@ class Mailer:
 
         # 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', '')
+        self.debug = os.environ.get('SENDMAILDEBUG', '') \
+            or config["MAIL_DEBUG"]
+
+        # set timezone so that things like formatdate(localtime=True)
+        # use the configured timezone
+        # apparently tzset doesn't exist in python under Windows, my bad.
+        # my pathetic attempts at googling a Windows-solution failed
+        # so if you're on Windows your mail won't use your configured
+        # timezone.
+        if hasattr(time, 'tzset'):
+            os.environ['TZ'] = get_timezone(self.config.TIMEZONE).tzname(None)
+            time.tzset()
 
     def get_standard_message(self, to, subject, author=None):
         '''Form a standard email message from Roundup.
@@ -53,8 +71,10 @@ class Mailer:
         writer.addheader('Subject', encode_header(subject, charset))
         writer.addheader('To', ', '.join(to))
         writer.addheader('From', author)
-        writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
-                                               time.gmtime()))
+        writer.addheader('Date', formatdate(localtime=True))
+
+        # add a Precedence header so autoresponders ignore us
+        writer.addheader('Precedence', 'bulk')
 
         # Add a unique Roundup header to help filtering
         writer.addheader('X-Roundup-Name', encode_header(tracker_name,
@@ -64,8 +84,8 @@ class Mailer:
         # finally, an aid to debugging problems
         writer.addheader('X-Roundup-Version', __version__)
 
-        writer.addheader('MIME-Version', '1.0')       
-        
+        writer.addheader('MIME-Version', '1.0')
+
         return message, writer
 
     def standard_message(self, to, subject, content, author=None):
@@ -114,7 +134,7 @@ class Mailer:
         part = writer.nextpart()
         part.addheader('Content-Transfer-Encoding', 'quoted-printable')
         body = part.startbody('text/plain; charset=utf-8')
-        body.write('\n'.join(error))
+        body.write(quopri.encodestring ('\n'.join(error)))
 
         # attach the original message to the returned message
         part = writer.nextpart()
@@ -135,8 +155,24 @@ class Mailer:
 
         writer.lastpart()
 
-        self.smtp_send(to, message)
-        
+        try:
+            self.smtp_send(to, message)
+        except MessageSendError:
+            # squash mail sending errors when bouncing mail
+            # TODO this *could* be better, as we could notify admin of the
+            # problem (even though the vast majority of bounce errors are
+            # because of spam)
+            pass
+
+    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.
 
@@ -168,29 +204,18 @@ 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)
-
-        # use TLS?
-        use_tls = getattr(config, 'MAILHOST_TLS', 'no')
-        if use_tls == 'yes':
-            # do we have key files too?
-            keyfile = getattr(config, 'MAILHOST_TLS_KEYFILE', '')
-            if keyfile:
-                certfile = getattr(config, 'MAILHOST_TLS_CERTFILE', '')
-                if certfile:
-                    args = (keyfile, certfile)
-                else:
-                    args = (keyfile, )
-            else:
-                args = ()
-            # start the TLS
-            self.starttls(*args)
+        smtplib.SMTP.__init__(self, config.MAILHOST, port=config['MAIL_PORT'],
+                              local_hostname=config['MAIL_LOCAL_HOSTNAME'])
+
+        # 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 = getattr(config, 'MAILUSER', None)
+        mailuser = config["MAIL_USERNAME"]
         if mailuser:
-            self.login(*config.MAILUSER)
+            self.login(mailuser, config["MAIL_PASSWORD"])
 
 # use the 'email' module, either imported, or our copied version
 try :
@@ -207,3 +232,5 @@ except ImportError :
             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 :
index 1435e0316c4f08bec842af74ae623f7e515ebc92..b3659b9a6b68dfe0b186957ec14e994e5c1b8269 100644 (file)
@@ -1,3 +1,4 @@
+# -*- coding: utf-8 -*-
 #
 # 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
@@ -15,8 +16,6 @@
 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 #
-# vim: ts=4 sw=4 expandtab
-#
 
 """An e-mail gateway for Roundup.
 
@@ -25,7 +24,7 @@ Incoming messages are examined for multiple parts:
    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. 
+   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.
 
@@ -35,7 +34,7 @@ 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. 
+the first non-quoting section becomes the summary of the message.
 
 Addresses
 ---------
@@ -48,23 +47,23 @@ 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. 
+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). 
+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. 
+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. 
+nodes.
 
 Triggers
 --------
@@ -72,18 +71,24 @@ 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. 
+explanatory message given in the exception.
 
-$Id: mailgw.py,v 1.148 2004-04-13 04:16:36 richard Exp $
+$Id: mailgw.py,v 1.196 2008-07-23 03:04:44 richard Exp $
 """
 __docformat__ = 'restructuredtext'
 
 import string, re, os, mimetools, cStringIO, smtplib, socket, binascii, quopri
-import time, random, sys
+import time, random, sys, logging
 import traceback, MimeWriter, rfc822
 
-from roundup import hyperdb, date, password, rfc2822, exceptions
-from roundup.mailer import Mailer
+from roundup import configuration, hyperdb, date, password, rfc2822, exceptions
+from roundup.mailer import Mailer, MessageSendError
+from roundup.i18n import _
+
+try:
+    import pyme, pyme.core, pyme.gpgme
+except ImportError:
+    pyme = None
 
 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
 
@@ -117,8 +122,6 @@ def initialiseSecurity(security):
         This function is directly invoked by security.Security.__init__()
         as a part of the Security object instantiation.
     '''
-    security.addPermission(name="Email Registration",
-        description="Anonymous may register through e-mail")
     p = security.addPermission(name="Email Access",
         description="User may use the email interface")
     security.addPermissionToRole('Admin', p)
@@ -143,6 +146,70 @@ def getparam(str, param):
                 return rfc822.unquote(f[i+1:].strip())
     return None
 
+def gpgh_key_getall(key, attr):
+    ''' return list of given attribute for all uids in
+        a key
+    '''
+    u = key.uids
+    while u:
+        yield getattr(u, attr)
+        u = u.next
+
+def gpgh_sigs(sig):
+    ''' more pythonic iteration over GPG signatures '''
+    while sig:
+        yield sig
+        sig = sig.next
+
+
+def iter_roles(roles):
+    ''' handle the text processing of turning the roles list
+        into something python can use more easily
+    '''
+    for role in [x.lower().strip() for x in roles.split(',')]:
+        yield role
+
+def user_has_role(db, userid, role_list):
+    ''' see if the given user has any roles that appear
+        in the role_list
+    '''
+    for role in iter_roles(db.user.get(userid, 'roles')):
+        if role in iter_roles(role_list):
+            return True
+    return False
+
+
+def check_pgp_sigs(sig, gpgctx, author):
+    ''' Theoretically a PGP message can have several signatures. GPGME
+        returns status on all signatures in a linked list. Walk that
+        linked list looking for the author's signature
+    '''
+    for sig in gpgh_sigs(sig):
+        key = gpgctx.get_key(sig.fpr, False)
+        # we really only care about the signature of the user who
+        # submitted the email
+        if key and (author in gpgh_key_getall(key, 'email')):
+            if sig.summary & pyme.gpgme.GPGME_SIGSUM_VALID:
+                return True
+            else:
+                # try to narrow down the actual problem to give a more useful
+                # message in our bounce
+                if sig.summary & pyme.gpgme.GPGME_SIGSUM_KEY_MISSING:
+                    raise MailUsageError, \
+                        _("Message signed with unknown key: %s") % sig.fpr
+                elif sig.summary & pyme.gpgme.GPGME_SIGSUM_KEY_EXPIRED:
+                    raise MailUsageError, \
+                        _("Message signed with an expired key: %s") % sig.fpr
+                elif sig.summary & pyme.gpgme.GPGME_SIGSUM_KEY_REVOKED:
+                    raise MailUsageError, \
+                        _("Message signed with a revoked key: %s") % sig.fpr
+                else:
+                    raise MailUsageError, \
+                        _("Invalid PGP signature detected.")
+
+    # we couldn't find a key belonging to the author of the email
+    raise MailUsageError, _("Message signed with unknown key: %s") % sig.fpr
+
 class Message(mimetools.Message):
     ''' subclass mimetools.Message so we can retrieve the parts of the
         message...
@@ -159,6 +226,17 @@ class Message(mimetools.Message):
             if not line:
                 break
             if line.strip() in (mid, end):
+                # according to rfc 1431 the preceding line ending is part of
+                # the boundary so we need to strip that
+                length = s.tell()
+                s.seek(-2, 1)
+                lineending = s.read(2)
+                if lineending == '\r\n':
+                    s.truncate(length - 2)
+                elif lineending[1] in ('\r', '\n'):
+                    s.truncate(length - 1)
+                else:
+                    raise ValueError('Unknown line ending in message.')
                 break
             s.write(line)
         if not s.getvalue().strip():
@@ -169,6 +247,7 @@ class Message(mimetools.Message):
     def getparts(self):
         """Get all parts of this multipart message."""
         # skip over the intro to the first boundary
+        self.fp.seek(0)
         self.getpart()
 
         # accumulate the other parts
@@ -211,7 +290,7 @@ class Message(mimetools.Message):
         data = None
         if encoding == 'base64':
             # BUG: is base64 really used for text encoding or
-            # are we inserting zip files here. 
+            # 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
@@ -223,67 +302,93 @@ class Message(mimetools.Message):
         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
-            edata = unicode(data, charset).encode('utf-8')
+            # 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 
+    #   Take the first text/plain part, anything else is considered an
     #   attachment.
-    # multipart/mixed: multiple "unrelated" parts.
-    # multipart/signed (rfc 1847): 
-    #   The control information is carried in the second of the two 
+    # 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 
+    # 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.
-    #   ACTION: Default. If we are lucky there is a text/plain.
-    #   TODO: One should use the start part and look for an Alternative
-    #   that is text/plain.
-    # multipart/Alternative (rfc 1872, 1892):
-    #   only in "related" ?
+    #   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 
+    #   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."""
+    def extract_content(self, parent_type=None, ignore_alternatives = False):
+        """Extract the body and the attachments recursively.
+
+           If the content is hidden inside a multipart/alternative part,
+           we use the *last* text/plain part of the *first*
+           multipart/alternative in the whole message.
+        """
         content_type = self.gettype()
         content = None
         attachments = []
-        
+
         if content_type == 'text/plain':
             content = self.getbody()
         elif content_type[:10] == 'multipart/':
+            content_found = bool (content)
+            ig = ignore_alternatives and not content_found
             for part in self.getparts():
-                new_content, new_attach = part.extract_content(content_type)
+                new_content, new_attach = part.extract_content(content_type,
+                    not content and ig)
 
                 # If we haven't found a text/plain part yet, take this one,
                 # otherwise make it an attachment.
                 if not content:
                     content = new_content
+                    cpart   = part
                 elif new_content:
-                    attachments.append(part.as_attachment())
-                    
+                    if content_found or content_type != 'multipart/alternative':
+                        attachments.append(part.text_as_attachment())
+                    else:
+                        # if we have found a text/plain in the current
+                        # multipart/alternative and find another one, we
+                        # use the first as an attachment (if configured)
+                        # and use the second one because rfc 2046, sec.
+                        # 5.1.4. specifies that later parts are better
+                        # (thanks to Philipp Gortan for pointing this
+                        # out)
+                        attachments.append(cpart.text_as_attachment())
+                        content = new_content
+                        cpart   = part
+
                 attachments.extend(new_attach)
+            if ig and content_type == 'multipart/alternative' and content:
+                attachments = []
         elif (parent_type == 'multipart/signed' and
               content_type == 'application/pgp-signature'):
             # ignore it so it won't be saved as an attachment
@@ -292,30 +397,121 @@ class Message(mimetools.Message):
             attachments.append(self.as_attachment())
         return content, attachments
 
+    def text_as_attachment(self):
+        """Return first text/plain part as Message"""
+        if not self.gettype().startswith ('multipart/'):
+            return self.as_attachment()
+        for part in self.getparts():
+            content_type = part.gettype()
+            if content_type == 'text/plain':
+                return part.as_attachment()
+            elif content_type.startswith ('multipart/'):
+                p = part.text_as_attachment()
+                if p:
+                    return p
+        return None
+
     def as_attachment(self):
         """Return this message as an attachment."""
         return (self.getname(), self.gettype(), self.getbody())
 
+    def pgp_signed(self):
+        ''' RFC 3156 requires OpenPGP MIME mail to have the protocol parameter
+        '''
+        return self.gettype() == 'multipart/signed' \
+            and self.typeheader.find('protocol="application/pgp-signature"') != -1
+
+    def pgp_encrypted(self):
+        ''' RFC 3156 requires OpenPGP MIME mail to have the protocol parameter
+        '''
+        return self.gettype() == 'multipart/encrypted' \
+            and self.typeheader.find('protocol="application/pgp-encrypted"') != -1
+
+    def decrypt(self, author):
+        ''' decrypt an OpenPGP MIME message
+            This message must be signed as well as encrypted using the "combined"
+            method. The decrypted contents are returned as a new message.
+        '''
+        (hdr, msg) = self.getparts()
+        # According to the RFC 3156 encrypted mail must have exactly two parts.
+        # The first part contains the control information. Let's verify that
+        # the message meets the RFC before we try to decrypt it.
+        if hdr.getbody() != 'Version: 1' or hdr.gettype() != 'application/pgp-encrypted':
+            raise MailUsageError, \
+                _("Unknown multipart/encrypted version.")
+
+        context = pyme.core.Context()
+        ciphertext = pyme.core.Data(msg.getbody())
+        plaintext = pyme.core.Data()
+
+        result = context.op_decrypt_verify(ciphertext, plaintext)
+
+        if result:
+            raise MailUsageError, _("Unable to decrypt your message.")
+
+        # we've decrypted it but that just means they used our public
+        # key to send it to us. now check the signatures to see if it
+        # was signed by someone we trust
+        result = context.op_verify_result()
+        check_pgp_sigs(result.signatures, context, author)
+
+        plaintext.seek(0,0)
+        # pyme.core.Data implements a seek method with a different signature
+        # than roundup can handle. So we'll put the data in a container that
+        # the Message class can work with.
+        c = cStringIO.StringIO()
+        c.write(plaintext.read())
+        c.seek(0)
+        return Message(c)
+
+    def verify_signature(self, author):
+        ''' verify the signature of an OpenPGP MIME message
+            This only handles detached signatures. Old style
+            PGP mail (i.e. '-----BEGIN PGP SIGNED MESSAGE----')
+            is archaic and not supported :)
+        '''
+        # we don't check the micalg parameter...gpgme seems to
+        # figure things out on its own
+        (msg, sig) = self.getparts()
+
+        if sig.gettype() != 'application/pgp-signature':
+            raise MailUsageError, \
+                _("No PGP signature found in message.")
+
+        context = pyme.core.Context()
+        # msg.getbody() is skipping over some headers that are
+        # required to be present for verification to succeed so
+        # we'll do this by hand
+        msg.fp.seek(0)
+        # according to rfc 3156 the data "MUST first be converted
+        # to its content-type specific canonical form. For
+        # text/plain this means conversion to an appropriate
+        # character set and conversion of line endings to the
+        # canonical <CR><LF> sequence."
+        # TODO: what about character set conversion?
+        canonical_msg = re.sub('(?<!\r)\n', '\r\n', msg.fp.read())
+        msg_data = pyme.core.Data(canonical_msg)
+        sig_data = pyme.core.Data(sig.getbody())
+
+        context.op_verify(sig_data, msg_data, None)
+
+        # check all signatures for validity
+        result = context.op_verify_result()
+        check_pgp_sigs(result.signatures, context, author)
+
 class MailGW:
 
-    # Matches subjects like:
-    # Re: "[issue1234] title of issue [status=resolved]"
-    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>[^[]+)?                             # issue title 
-        "?                                            # Trailing "
-        (\[(?P<args>.+?)\])?                          # [prop=value]
-        ''', re.IGNORECASE|re.VERBOSE)
-
-    def __init__(self, instance, db, arguments={}):
+    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)
@@ -368,7 +564,7 @@ class MailGW:
         fcntl.flock(f.fileno(), FCNTL.LOCK_UN)
         return 0
 
-    def do_imap(self, server, user='', password='', mailbox='', ssl=False):
+    def do_imap(self, server, user='', password='', mailbox='', ssl=0):
         ''' Do an IMAP connection
         '''
         import getpass, imaplib, socket
@@ -384,25 +580,19 @@ class MailGW:
         # open a connection to the server and retrieve all messages
         try:
             if ssl:
-                print 'Trying server "%s" with ssl' % server
+                self.logger.debug('Trying server %r with ssl'%server)
                 server = imaplib.IMAP4_SSL(server)
             else:
-                print 'Trying server %s without ssl' % server
+                self.logger.debug('Trying server %r without ssl'%server)
                 server = imaplib.IMAP4(server)
-        except imaplib.IMAP4.error, e:
-            print 'IMAP server error:', e
-            return 1
-        except socket.error, e:
-            print 'SOCKET error:', e
-            return 1
-        except socket.sslerror, e:
-            print 'SOCKET ssl error:', e
+        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:
-            print 'Login failure:', e
+            self.logger.exception('IMAP login failure')
             return 1
 
         try:
@@ -411,12 +601,14 @@ class MailGW:
             else:
                 (typ, data) = server.select(mailbox=mailbox)
             if typ != 'OK':
-                print 'Failed to get mailbox "%s": %s'%(mailbox, data)
+                self.logger.error('Failed to get mailbox %r: %s'%(mailbox,
+                    data))
                 return 1
             try:
                 numMessages = int(data[0])
             except ValueError, value:
-                print 'Invalid message count from mailbox %r'%data[0]
+                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)')
@@ -439,12 +631,17 @@ class MailGW:
         return 0
 
 
-    def do_apop(self, server, user='', password=''):
+    def do_apop(self, server, user='', password='', ssl=False):
         ''' Do authentication POP
         '''
-        self.do_pop(server, user, password, apop=1)
+        self._do_pop(server, user, password, True, ssl)
+
+    def do_pop(self, server, user='', password='', ssl=False):
+        ''' Do plain POP
+        '''
+        self._do_pop(server, user, password, False, ssl)
 
-    def do_pop(self, server, user='', password='', apop=0):
+    def _do_pop(self, server, user, password, apop, ssl):
         '''Read a series of messages from the specified POP server.
         '''
         import getpass, poplib, socket
@@ -460,9 +657,13 @@ class MailGW:
 
         # open a connection to the server and retrieve all messages
         try:
-            server = poplib.POP3(server)
-        except socket.error, message:
-            print "POP server error:", message
+            if ssl:
+                klass = poplib.POP3_SSL
+            else:
+                klass = poplib.POP3
+            server = klass(server)
+        except socket.error:
+            self.logger.exception('POP server error')
             return 1
         if apop:
             server.apop(user, password)
@@ -471,7 +672,7 @@ class MailGW:
             server.pass_(password)
         numMessages = len(server.list()[1])
         for i in range(1, numMessages+1):
-            # retr: returns 
+            # retr: returns
             # [ pop response e.g. '+OK 459 octets',
             #   [ array of message lines ],
             #   number of octets ]
@@ -508,23 +709,22 @@ class MailGW:
             sendto = message.getaddrlist('from')
         if not sendto:
             # very bad-looking message - we don't even know who sent it
-            # XXX we should use a log file here...
-            
-            sendto = [self.instance.config.ADMIN_EMAIL]
-
-            m = ['Subject: badly formed message from mail gateway']
-            m.append('')
-            m.append('The mail gateway retrieved a message which has no From:')
-            m.append('line, indicating that it is corrupt. Please check your')
-            m.append('mail gateway source. Failed message is attached.')
-            m.append('')
-            self.mailer.bounce_message(message, sendto, m,
-                subject='Badly formed message from mail gateway')
+            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:
@@ -549,25 +749,30 @@ class MailGW:
             m.append(str(value))
             self.mailer.bounce_message(message, [sendto[0][1]], m)
         except IgnoreMessage:
-            # XXX we should use a log file here...
             # 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
-            # XXX we should use a log file here...
             # 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')
-            m.append('----  traceback of failure  ----')
-            s = cStringIO.StringIO()
-            import traceback
-            traceback.print_exc(None, s)
-            m.append(s.getvalue())
-            self.mailer.bounce_message(message, sendto, m)
+            self.mailer.bounce_message(message, [sendto[0][1]], m)
+
+            m.append('----------------')
+            m.append(traceback.format_exc())
+            self.mailer.bounce_message(message, [self.instance.config.ADMIN_EMAIL], m)
 
     def handle_message(self, message):
         ''' message - a Message instance
@@ -578,17 +783,36 @@ class MailGW:
         if message.getheader('x-roundup-loop', ''):
             raise IgnoreLoop
 
-        # detect Precedence: Bulk
-        if (message.getheader('precedence', '') == 'bulk'):
+        # 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@some.dom.ain
         # use the embedded issue number as our issue
-#        if hasattr(self.instance.config, 'EMAIL_ISSUE_ADDRESS_RE') and \
-#                self.instance.config.EMAIL_ISSUE_ADDRESS_RE:
-#            issue_re = self.instance.config.EMAIL_ISSUE_ADDRESS_RE
+#        issue_re = config['MAILGW_ISSUE_ADDRESS_RE']
+#        if issue_re:
 #            for header in ['to', 'cc', 'bcc']:
 #                addresses = message.getheader(header, '')
 #            if addresses:
@@ -599,49 +823,94 @@ class MailGW:
 #                    nodeid = issue.group('nodeid')
 #                    break
 
-        # determine the sender's address
-        from_list = message.getaddrlist('resent-from')
-        if not from_list:
-            from_list = message.getaddrlist('from')
+        # Matches subjects like:
+        # Re: "[issue1234] title of issue [status=resolved]"
 
-        # handle the subject line
-        subject = message.getheader('subject', '')
+        # Alias since we need a reference to the original subject for
+        # later use in error messages
+        tmpsubject = subject
 
-        if not subject:
-            raise MailUsageError, '''
-Emails to Roundup trackers must include a Subject: line!
-'''
+        sd_open, sd_close = config['MAILGW_SUBJECT_SUFFIX_DELIMITERS']
+        delim_open = re.escape(sd_open)
+        if delim_open in '[(': delim_open = '\\' + delim_open
+        delim_close = re.escape(sd_close)
+        if delim_close in '[(': delim_close = '\\' + delim_close
 
-        if subject.strip().lower() == 'help':
-            raise MailUsageHelp
+        matches = dict.fromkeys(['refwd', 'quote', 'classname',
+                                 'nodeid', 'title', 'args',
+                                 'argswhole'])
 
-        m = self.subject_re.match(subject)
+        # Look for Re: et. al. Used later on for MAILGW_SUBJECT_CONTENT_MATCH
+        re_re = r"(?P<refwd>%s)\s*" % config["MAILGW_REFWD_RE"].pattern
+        m = re.match(re_re, tmpsubject, re.IGNORECASE|re.VERBOSE|re.UNICODE)
+        if m:
+            m = m.groupdict()
+            if m['refwd']:
+                matches.update(m)
+                tmpsubject = tmpsubject[len(m['refwd']):] # Consume Re:
+
+        # Look for Leading "
+        m = re.match(r'(?P<quote>\s*")', tmpsubject,
+                     re.IGNORECASE)
+        if m:
+            matches.update(m.groupdict())
+            tmpsubject = tmpsubject[len(matches['quote']):] # Consume quote
+
+        has_prefix = re.search(r'^%s(\w+)%s'%(delim_open,
+            delim_close), tmpsubject.strip())
 
-        # check for well-formed subject line
+        class_re = r'%s(?P<classname>(%s))(?P<nodeid>\d+)?%s'%(delim_open,
+            "|".join(self.db.getclasses()), delim_close)
+        # Note: re.search, not re.match as there might be garbage
+        # (mailing list prefix, etc.) before the class identifier
+        m = re.search(class_re, tmpsubject, re.IGNORECASE)
         if m:
-            # get the classname
-            classname = m.group('classname')
-            if classname is None:
-                # no classname, check if this a registration confirmation email
-                # or fallback on the default class
-                otk_re = re.compile('-- key (?P<otk>[a-zA-Z0-9]{32})')
-                otk = otk_re.search(m.group('title'))
-                if otk:
-                    self.db.confirm_registration(otk.group('otk'))
-                    subject = 'Your registration to %s is complete' % \
-                              self.instance.config.TRACKER_NAME
-                    sendto = [from_list[0][1]]
-                    self.mailer.standard_message(sendto, subject, '') 
-                    return
-                elif hasattr(self.instance.config, 'MAIL_DEFAULT_CLASS') and \
-                         self.instance.config.MAIL_DEFAULT_CLASS:
-                    classname = self.instance.config.MAIL_DEFAULT_CLASS
-                else:
-                    # fail
-                    m = None
+            matches.update(m.groupdict())
+            # Skip to the end of the class identifier, including any
+            # garbage before it.
+
+            tmpsubject = tmpsubject[m.end():]
 
-        if not m:
-            raise MailUsageError, """
+        # if we've not found a valid classname prefix then force the
+        # scanning to handle there being a leading delimiter
+        title_re = r'(?P<title>%s[^%s]+)'%(
+            not matches['classname'] and '.' or '', delim_open)
+        m = re.match(title_re, tmpsubject.strip(), re.IGNORECASE)
+        if m:
+            matches.update(m.groupdict())
+            tmpsubject = tmpsubject[len(matches['title']):] # Consume title
+
+        args_re = r'(?P<argswhole>%s(?P<args>.+?)%s)?'%(delim_open,
+            delim_close)
+        m = re.search(args_re, tmpsubject.strip(), re.IGNORECASE|re.VERBOSE)
+        if m:
+            matches.update(m.groupdict())
+
+        # figure subject line parsing modes
+        pfxmode = config['MAILGW_SUBJECT_PREFIX_PARSING']
+        sfxmode = config['MAILGW_SUBJECT_SUFFIX_PARSING']
+
+        # 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(matches['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 = matches['classname']
+
+        if not classname and has_prefix 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:
@@ -652,26 +921,70 @@ line. The subject must contain a class name or designator to indicate the
       - this will append the message's contents to the existing issue 1234
         in the tracker.
 
-Subject was: '%s'
-"""%subject
+Subject was: '%(subject)s'
+""") % locals()
 
-        # get the class
-        try:
-            cl = self.db.getclass(classname)
-        except KeyError:
-            raise MailUsageError, '''
-The class name you identified in the subject line ("%s") does not exist in the
-database.
+        # try to get the class specified - if "loose" or "none" then fall
+        # back on the default
+        attempts = []
+        if classname:
+            attempts.append(classname)
 
-Valid class names are: %s
-Subject was: "%s"
-'''%(classname, ', '.join(self.db.getclasses()), subject)
+        if self.default_class:
+            attempts.append(self.default_class)
+        else:
+            attempts.append(config['MAILGW_DEFAULT_CLASS'])
+
+        # first valid class name wins
+        cl = None
+        for trycl in attempts:
+            try:
+                cl = self.db.getclass(trycl)
+                classname = trycl
+                break
+            except KeyError:
+                pass
+
+        if not cl:
+            validname = ', '.join(self.db.getclasses())
+            if classname:
+                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()
+            else:
+                raise MailUsageError, _("""
+You did not identify a class name in the subject line and there is no
+default set for this tracker. 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()
 
         # get the optional nodeid
-        nodeid = m.group('nodeid')
+        if pfxmode == 'none':
+            nodeid = None
+        else:
+            nodeid = matches['nodeid']
+
+        # try in-reply-to to match the message if there's no nodeid
+        inreplyto = message.getheader('in-reply-to') or ''
+        if nodeid is None and inreplyto:
+            l = self.db.getclass('msg').stringFind(messageid=inreplyto)
+            if l:
+                nodeid = cl.filter(None, {'messages':l})[0]
 
         # title is optional too
-        title = m.group('title')
+        title = matches['title']
         if title:
             title = title.strip()
         else:
@@ -679,37 +992,53 @@ Subject was: "%s"
 
         # strip off the quotes that dumb emailers put around the subject, like
         #      Re: "[issue1] bla blah"
-        if m.group('quote') and title.endswith('"'):
+        if matches['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, '''
+            raise MailUsageError, _("""
 I cannot match your message to a node in the database - you need to either
-supply a full node identifier (with number, eg "[issue123]" or keep the
+supply a full designator (with number, eg "[issue123]") or keep the
 previous subject title intact so I can match that.
 
-Subject was: "%s"
-'''%subject
+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...)
-        if nodeid is None and m.group('refwd'):
+        # 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 matches['refwd']:
             l = cl.stringFind(title=title)
-            if l:
-                nodeid = l[-1]
+            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):
-            raise MailUsageError, '''
-The node specified by the designator in the subject of your message ("%s")
-does not exist.
+            if pfxmode == 'strict':
+                raise MailUsageError, _("""
+The node specified by the designator in the subject of your message
+("%(nodeid)s") does not exist.
 
-Subject was: "%s"
-'''%(nodeid, subject)
+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
@@ -724,12 +1053,16 @@ Subject was: "%s"
             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'):
-                        raise MailUsageError, '''
+                        mailadmin = config['ADMIN_EMAIL']
+                        raise MailUsageError, _("""
 The mail gateway is not properly set up. Please contact
-%s and have them fix the incorrect class specified as:
-  %s
-'''%(self.instance.config.ADMIN_EMAIL, current_class)
+%(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,
@@ -747,11 +1080,12 @@ The mail gateway is not properly set up. Please contact
                         errors, user_props = setPropArrayFromString(self,
                             temp_cl, propstring.strip())
                     if errors:
-                        raise MailUsageError, '''
+                        mailadmin = config['ADMIN_EMAIL']
+                        raise MailUsageError, _("""
 The mail gateway is not properly set up. Please contact
-%s and have them fix the incorrect properties:
-  %s
-'''%(self.instance.config.ADMIN_EMAIL, errors)
+%(mailadmin)s and have them fix the incorrect properties:
+  %(errors)s
+""") % locals()
 
         #
         # handle the users
@@ -759,7 +1093,8 @@ The mail gateway is not properly set up. Please contact
         # 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('Email Registration', anonid):
+        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
@@ -775,35 +1110,53 @@ The mail gateway is not properly set up. Please contact
         if not self.db.security.hasPermission('Email Access', author):
             if author == anonid:
                 # we're anonymous and we need to be a registered user
-                raise Unauthorized, '''
-You are not a registered user.
+                from_address = from_list[0][1]
+                registration_info = ""
+                if self.db.security.hasPermission('Web Access', author) and \
+                   self.db.security.hasPermission('Create', anonid, 'user'):
+                    tracker_web = self.instance.config.TRACKER_WEB
+                    registration_info = """ Please register at:
+
+%(tracker_web)suser?template=register
+
+...before sending mail to the tracker.""" % locals()
+
+                raise Unauthorized, _("""
+You are not a registered user.%(registration_info)s
 
-Unknown address: %s
-'''%from_list[0][1]
+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 this class of information
-        if not self.db.security.hasPermission('Edit', author, classname):
-            raise Unauthorized, 'You are not permitted to edit %s.'%classname
+                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()
 
-        # reopen the database as the author
+        # set the database user as the author
         username = self.db.user.get(author, 'username')
-        self.db.close()
-        self.db = self.instance.open(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 = self.instance.config.TRACKER_EMAIL.lower()
+        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:
@@ -823,59 +1176,95 @@ Unknown address: %s
         # figure what the properties of this Class are
         properties = cl.getprops()
         props = {}
-        args = m.group('args')
+        args = matches['args']
+        argswhole = matches['argswhole']
         if args:
-            errors, props = setPropArrayFromString(self, cl, args, nodeid)
-            # handle any errors parsing the argument list
-            if errors:
-                errors = '\n- '.join(map(str, errors))
-                raise MailUsageError, '''
+            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:
-- %s
+- %(errors)s
 
-Subject was: "%s"
-'''%(errors, subject)
+Subject was: "%(subject)s"
+""") % locals()
+                    else:
+                        title += ' ' + argswhole
 
 
         # set the issue title to the subject
-        if properties.has_key('title') and not issue_props.has_key('title'):
-            issue_props['title'] = title.strip()
+        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, self.instance.config.MAIL_DOMAIN)
+                classname, nodeid, config['MAIL_DOMAIN'])
 
+        # if they've enabled PGP processing then verify the signature
+        # or decrypt the message
+
+        # if PGP_ROLES is specified the user must have a Role in the list
+        # or we will skip PGP processing
+        def pgp_role():
+            if self.instance.config.PGP_ROLES:
+                return user_has_role(self.db, author,
+                    self.instance.config.PGP_ROLES)
+            else:
+                return True
+
+        if self.instance.config.PGP_ENABLE and pgp_role():
+            assert pyme, 'pyme is not installed'
+            # signed/encrypted mail must come from the primary address
+            author_address = self.db.user.get(author, 'address')
+            if self.instance.config.PGP_HOMEDIR:
+                os.environ['GNUPGHOME'] = self.instance.config.PGP_HOMEDIR
+            if message.pgp_signed():
+                message.verify_signature(author_address)
+            elif message.pgp_encrypted():
+                # replace message with the contents of the decrypted
+                # message for content extraction
+                # TODO: encrypted message handling is far from perfect
+                # bounces probably include the decrypted message, for
+                # instance :(
+                message = message.decrypt(author_address)
+            else:
+                raise MailUsageError, _("""
+This tracker has been configured to require all email be PGP signed or
+encrypted.""")
         # now handle the body - find the message
-        content, attachments = message.extract_content()
+        ig = self.instance.config.MAILGW_IGNORE_ALTERNATIVES
+        content, attachments = message.extract_content(ignore_alternatives = ig)
         if content is None:
-            raise MailUsageError, '''
+            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 = getattr(self.instance.config, 'EMAIL_KEEP_QUOTED_TEXT',
-            'no') == 'yes'
-        keep_body = getattr(self.instance.config, 'EMAIL_LEAVE_BODY_UNCHANGED',
-            'no') == 'yes'
+""")
 
         # parse the body of the message, stripping out bits as appropriate
-        summary, content = parseContent(content, keep_citations, 
-            keep_body)
+        summary, content = parseContent(content, config=config)
         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:
@@ -886,6 +1275,12 @@ not find a text/plain part to use.
                 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')
@@ -895,27 +1290,39 @@ not find a text/plain part to use.
                 # 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:
-                pass
+            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:
-                # attach the message to the node
-                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]
+                # pre-load the messages list
+                props['messages'] = [message_id]
 
         #
         # perform the node change / create
@@ -927,22 +1334,30 @@ not find a text/plain part to use.
             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, '''
+        except (TypeError, IndexError, ValueError, exceptions.Reject), message:
+            raise MailUsageError, _("""
 There was a problem with the message you sent:
-   %s
-'''%message
+   %(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[..])
@@ -954,8 +1369,8 @@ def setPropArrayFromString(self, cl, propString, nodeid=None):
         try:
             propname, value = prop.split('=')
         except ValueError, message:
-            errors.append('not of form [arg=value,value,...;'
-                'arg=value,value,...]')
+            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()
@@ -963,7 +1378,7 @@ def setPropArrayFromString(self, cl, propString, nodeid=None):
             props[propname] = hyperdb.rawToHyperdb(self.db, cl, nodeid,
                 propname, value)
         except hyperdb.HyperdbValueError, message:
-            errors.append(message)
+            errors.append(str(message))
     return errors, props
 
 
@@ -1039,30 +1454,45 @@ def uidFromAddress(db, address, create=1, **user_props):
     else:
         return 0
 
+def parseContent(content, keep_citations=None, keep_body=None, config=None):
+    """Parse mail message; return message summary and stripped content
 
-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. 
+    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.
+
+    Arguments:
+
+        keep_citations: declared for backward compatibility.
+            If omitted or None, use config["MAILGW_KEEP_QUOTED_TEXT"]
+
+        keep_body: declared for backward compatibility.
+            If omitted or None, use config["MAILGW_LEAVE_BODY_UNCHANGED"]
+
+        config: tracker configuration object.
+            If omitted or None, use default configuration.
+
+    """
+    if config is None:
+        config = configuration.CoreConfig()
+    if keep_citations is None:
+        keep_citations = config["MAILGW_KEEP_QUOTED_TEXT"]
+    if keep_body is None:
+        keep_body = config["MAILGW_LEAVE_BODY_UNCHANGED"]
+    eol = config["MAILGW_EOL_RE"]
+    signature = config["MAILGW_SIGN_RE"]
+    original_msg = config["MAILGW_ORIGMSG_RE"]
 
-        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:])
+        sections = config["MAILGW_BLANKLINE_RE"].split(content[i:])
     else:
-        sections = blank_line.split(content)
+        sections = config["MAILGW_BLANKLINE_RE"].split(content)
 
     # extract out the summary from the message
     summary = ''
@@ -1122,4 +1552,4 @@ def parseContent(content, keep_citations, keep_body,
 
     return summary, content
 
-# vim: set filetype=python ts=4 sw=4 et si
+# vim: set filetype=python sts=4 sw=4 et si :
diff --git a/roundup/msgfmt.py b/roundup/msgfmt.py
new file mode 100644 (file)
index 0000000..456cfdf
--- /dev/null
@@ -0,0 +1,224 @@
+#! /usr/bin/env python
+# -*- coding: iso-8859-1 -*-
+# Written by Martin v. Löwis <loewis@informatik.hu-berlin.de>
+# Plural forms support added by alexander smishlajev <alex@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 = {}
+
+
+\f
+def usage(code, msg=''):
+    print >> sys.stderr, __doc__
+    if msg:
+        print >> sys.stderr, msg
+    sys.exit(code)
+
+
+\f
+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
+
+
+\f
+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
+
+
+\f
+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
+
+
+\f
+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 :
index 14dbcda0da9338193fbd2b128dbf0881be899778..6a555bb908ae9c5187db03dc414a6d1545251713 100644 (file)
 # 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.12 2004-03-22 07:45:39 richard Exp $
+#
+# $Id: password.py,v 1.15 2005-12-25 15:38:40 a1s Exp $
 
 """Password handling (encoding, decoding).
 """
 __docformat__ = 'restructuredtext'
 
-import sha, re, string, random
+import sha, md5, re, string, random
 try:
     import crypt
 except:
@@ -39,9 +39,11 @@ def encodePassword(plaintext, scheme, other=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[:2]
+            salt = other
         else:
             saltchars = './0123456789'+string.letters
             salt = random.choice(saltchars) + random.choice(saltchars)
@@ -57,10 +59,10 @@ def generatePassword(length=8):
     return ''.join([random.choice(chars) for x in range(length)])
 
 class Password:
-    '''The class encapsulates a Password property type value in the database. 
+    '''The class encapsulates a Password property type value in the database.
 
-    The encoding of the password is one if None, 'SHA' or 'plaintext'. The
-    encodePassword function is used to actually encode the password from
+    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.
 
@@ -87,15 +89,15 @@ class Password:
         if scheme is None:
             scheme = self.default_scheme
         if plaintext is not None:
-            self.password = encodePassword(plaintext, self.default_scheme)
-            self.scheme = self.default_scheme
+            self.setPassword (plaintext, scheme)
         elif encrypted is not None:
-            self.unpack(encrypted)
+            self.unpack(encrypted, scheme)
         else:
-            self.password = None
             self.scheme = self.default_scheme
+            self.password = None
+            self.plaintext = None
 
-    def unpack(self, encrypted):
+    def unpack(self, encrypted, scheme=None):
         '''Set the password info from the scheme:<encryted info> string
            (the inverse of __str__)
         '''
@@ -103,16 +105,18 @@ class Password:
         if m:
             self.scheme = m.group(1)
             self.password = m.group(2)
+            self.plaintext = None
         else:
             # currently plaintext - encrypt
-            self.password = encodePassword(encrypted, self.default_scheme)
-            self.scheme = self.default_scheme
+            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.'''
@@ -142,6 +146,13 @@ def test():
     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'
@@ -152,4 +163,4 @@ def test():
 if __name__ == '__main__':
     test()
 
-# vim: set filetype=python ts=4 sw=4 et si
+# vim: set filetype=python sts=4 sw=4 et si :
diff --git a/roundup/rcsv.py b/roundup/rcsv.py
deleted file mode 100644 (file)
index e8a4c8e..0000000
+++ /dev/null
@@ -1,79 +0,0 @@
-"""Supplies a Python-2.3 Object Craft csv module work-alike to the extent
-needed by Roundup using the Python 2.3 csv module.
-"""
-__docformat__ = 'restructuredtext'
-
-from roundup.i18n import _
-from cStringIO import StringIO
-error = """
-Sorry, you need a csv module. Either upgrade your Python to 2.3 or later,
-or get and install the csv module from:
-http://www.object-craft.com.au/projects/csv/
-"""
-try:
-    import csv
-    try:
-        _reader = csv.reader
-        writer = csv.writer
-        excel = csv.excel
-        error = ''
-    except AttributeError:
-        # fake it all up using the Object-Craft CSV module
-        class excel:
-            delimiter = ':' 
-        if hasattr(csv, 'parser'):
-            error = ''
-            def _reader(fileobj, dialect=excel):
-                # note real readers take an iterable but 2.1 doesn't
-                # support iterable access to file objects.
-                result = []
-                p = csv.parser(field_sep=dialect.delimiter)
-
-                while 1:
-                    line = fileobj.readline()
-                    if not line: break
-                    # parse lines until we get a complete entry
-                    while 1:
-                        fields = p.parse(line)
-                        if fields: break
-                        line = fileobj.readline()
-                        if not line:
-                            raise ValueError, "Unexpected EOF during CSV parse"
-                    result.append(fields)
-                return result
-            class writer:
-                def __init__(self, fileobj, dialect=excel):
-                    self.fileobj = fileobj
-                    self.p = csv.parser(field_sep = dialect.delimiter)
-                def writerow(self, fields):
-                    print >>self.fileobj, self.p.join(fields)
-                def writerows(self, rows):
-                    for fields in rows:
-                        print >>self.fileobj, self.p.join(fields)
-
-except ImportError:
-    class excel:
-        delimiter = ':' 
-       
-class colon_separated(excel):
-    delimiter = ':' 
-class comma_separated(excel):
-    delimiter = ',' 
-
-def reader(fileobject, dialect=excel):
-    csv_lines = [line for line in fileobject.readlines() if line.strip()]
-    return _reader(StringIO(''.join(csv_lines)), dialect)
-
-if __name__ == "__main__":
-    f=open('testme.txt', 'r')
-    r = reader(f, colon_separated)
-    remember = []
-    for record in r:
-        print record
-        remember.append(record)
-    f.close()
-    import sys
-    w = writer(sys.stdout, colon_separated)
-    w.writerows(remember)
-
index 8fba2ad819de83f806b61960f6819063f5392d27..7e016c280fca036118bae1bc875e9bc08b4b3402 100644 (file)
@@ -18,6 +18,8 @@ ecre = re.compile(r'''
 
 hqre = re.compile(r'^[A-z0-9!"#$%%&\'()*+,-./:;<=>?@\[\]^_`{|}~ ]+$')
 
+CRLF = '\r\n'
+
 def base64_decode(s, convert_eols=None):
     """Decode a raw base64 string.
 
@@ -139,7 +141,7 @@ def encode_header(header, charset='utf-8'):
         if c == ' ':
             quoted += '_'
         # These characters can be included verbatim
-        elif hqre.match(c):
+        elif hqre.match(c) and c not in '_=?':
             quoted += c
         # Otherwise, replace with hex value like =E2
         else:
index d59d41e64e8970f2d37d0c42a49331b0e00fa615..1ddc30aac7f1f75e63cbe1a18e70524e4391bf36 100644 (file)
@@ -1,3 +1,4 @@
+from __future__ import nested_scopes
 #
 # 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
 # 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.106 2004-04-05 06:13:42 richard Exp $
+#
+# $Id: roundupdb.py,v 1.139 2008-08-07 06:31:16 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
+import os.path
 
 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."""
@@ -43,20 +50,40 @@ class Database:
             # admin user may not exist, but always has ID 1
             return '1'
         else:
-            return self.user.lookup(self.journaltag)
+            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()
+        timezone = None
         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)
+            tz = self.user.get(userid, 'timezone')
+            date.get_timezone(tz)
+            timezone = tz
+        except KeyError:
+            pass
+        # If there is no class 'user' or current user doesn't have timezone
+        # property or that property is not set assume he/she lives in
+        # the timezone set in the tracker config.
+        if timezone is None:
+            timezone = self.config['TIMEZONE']
         return timezone
 
     def confirm_registration(self, otk):
@@ -78,13 +105,13 @@ class Database:
 
         # 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
 
 
@@ -107,6 +134,23 @@ class IssueClass:
     - 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, keyword, priority, status.
+    (
+        ''"title", ''"messages", ''"files", ''"nosy", ''"superseder",
+        ''"assignedto", ''"keyword", ''"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.
@@ -121,32 +165,46 @@ class IssueClass:
         appended to the "messages" field of the specified issue.
         """
 
-    # XXX "bcc" is an optional extra here...
     def nosymessage(self, nodeid, msgid, oldvalues, whichnosy='nosy',
-            from_address=None, cc=[]): #, bcc=[]):
+            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.
         """
-        authid = self.db.msg.safeget(msgid, 'author')
-        recipients = self.db.msg.safeget(msgid, 'recipients', [])
-        
+        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):
+        def add_recipient(userid, to):
             # make sure they have an address
             address = self.db.user.get(userid, 'address')
             if address:
-                sendto.append(address)
+                to.append(address)
                 recipients.append(userid)
 
         def good_recipient(userid):
@@ -161,15 +219,20 @@ class IssueClass:
         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)
-        
+            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)        
+                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)
@@ -178,24 +241,30 @@ class IssueClass:
 
         # If we have new recipients, update the message's recipients
         # and send the mail.
-        if sendto:
-            if msgid:
+        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)
+            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):
+    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
-       
-        inreplyto = messages.safeget(msgid, 'inreplyto')
-        messageid = messages.safeget(msgid, 'messageid')
+
+        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:
@@ -204,20 +273,27 @@ class IssueClass:
             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
                                            self.classname, nodeid,
                                            self.db.config.MAIL_DOMAIN)
-            messages.set(msgid, messageid=messageid)
+            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
-        authid = messages.safeget(msgid, 'author')
-        authname = users.safeget(authid, 'realname')
+        if msgid:
+            authid = messages.get(msgid, 'author')
+        else:
+            authid = self.db.getuid()
+        authname = users.get(authid, 'realname')
         if not authname:
-            authname = users.safeget(authid, 'username', '')
-        authaddr = users.safeget(authid, 'address', '')
-        if authaddr:
+            authname = users.get(authid, 'username', '')
+        authaddr = users.get(authid, 'address', '')
+
+        if authaddr and self.db.config.MAIL_ADD_AUTHOREMAIL:
             authaddr = " <%s>" % straddr( ('',authaddr) )
+        elif authaddr:
+            authaddr = ""
 
         # make the message body
         m = ['']
@@ -227,17 +303,36 @@ class IssueClass:
             m.append(self.email_signature(nodeid, msgid))
 
         # add author information
-        if authid:
-            if len(self.get(nodeid,'messages')) == 1:
-                m.append("New submission from %s%s:"%(authname, authaddr))
+        if authid and self.db.config.MAIL_ADD_AUTHORINFO:
+            if msgid and len(self.get(nodeid, 'messages')) == 1:
+                m.append(_("New submission from %(authname)s%(authaddr)s:")
+                    % locals())
+            elif msgid:
+                m.append(_("%(authname)s%(authaddr)s added the comment:")
+                    % locals())
             else:
-                m.append("%s%s added the comment:"%(authname, authaddr))
-        else:
-            m.append("System message:")
-        m.append('')
+                m.append(_("Change by %(authname)s%(authaddr)s:") % locals())
+            m.append('')
 
         # add the content
-        m.append(messages.safeget(msgid, 'content', ''))
+        if msgid is not None:
+            m.append(messages.get(msgid, 'content', ''))
+
+        # get the files for this message
+        message_files = []
+        if msgid :
+            for fileid in messages.get(msgid, 'files') :
+                # check the attachment size
+                filename = self.db.filename('file', fileid, None)
+                filesize = os.path.getsize(filename)
+                if filesize <= self.db.config.NOSY_MAX_ATTACHMENT_SIZE:
+                    message_files.append(fileid)
+                else:
+                    base = self.db.config.TRACKER_WEB
+                    link = "".join((base, files.classname, fileid))
+                    filename = files.get(fileid, 'name')
+                    m.append(_("File '%(filename)s' not attached - "
+                        "you can download it from %(link)s.") % locals())
 
         # add the change note
         if note:
@@ -257,9 +352,6 @@ class IssueClass:
         quopri.encode(content, content_encoded, 0)
         content_encoded = content_encoded.getvalue()
 
-        # get the files for this message
-        message_files = msgid and messages.get(msgid, 'files') or None
-
         # make sure the To line is always the same (for testing mostly)
         sendto.sort()
 
@@ -275,67 +367,127 @@ class IssueClass:
         subject = '[%s%s] %s'%(cn, nodeid, title)
         author = (authname + from_tag, from_address)
 
-        # 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)
-                    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()
+        # send an individual message per recipient?
+        if self.db.config.NOSY_EMAIL_SENDING != 'single':
+            sendto = [[address] for address in sendto]
         else:
-            writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
-            body = writer.startbody('text/plain; charset=%s'%charset)
-            body.write(content_encoded)
+            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)
+
+            # Generate a header for each link or multilink to
+            # a class that has a name attribute
+            for propname, prop in self.getprops().items():
+                if not isinstance(prop, (hyperdb.Link, hyperdb.Multilink)):
+                    continue
+                cl = self.db.getclass(prop.classname)
+                if not 'name' in cl.getprops():
+                    continue
+                if isinstance(prop, hyperdb.Link):
+                    value = self.get(nodeid, propname)
+                    if value is None:
+                        continue
+                    values = [value]
+                else:
+                    values = self.get(nodeid, propname)
+                    if not values:
+                        continue
+                values = [cl.get(v, 'name') for v in values]
+                values = ', '.join(values)
+                writer.addheader("X-Roundup-%s-%s" % (self.classname, propname),
+                                 values)
+            if not inreplyto:
+                # Default the reply to the first message
+                msgs = self.get(nodeid, 'messages')
+                # Assume messages are sorted by increasing message number here
+                # If the issue is just being created, and the submitter didn't
+                # provide a message, then msgs will be empty.
+                if msgs and msgs[0] != nodeid:
+                    inreplyto = messages.get(msgs[0], '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)
 
-        mailer.smtp_send(sendto, message)
+            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 
+        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 " \
@@ -362,9 +514,9 @@ class IssueClass:
 
         # list the values
         m = []
-        l = props.items()
-        l.sort()
-        for propname, prop in l:
+        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:
@@ -386,6 +538,10 @@ class IssueClass:
                     value = [link.get(entry, key) for entry in value]
                 value.sort()
                 value = ', '.join(value)
+            else:
+                value = str(value)
+                if '\n' in value:
+                    value = '\n'+self.indentChangeNoteValue(value)
             m.append('%s: %s'%(propname, value))
         m.insert(0, '----------')
         m.insert(0, '')
@@ -394,10 +550,9 @@ class IssueClass:
     def generateChangeNote(self, nodeid, oldvalues):
         """Generate a change note that lists property changes
         """
-        if __debug__ :
-            if not isinstance(oldvalues, type({})) :
-                raise TypeError("'oldvalues' must be dict-like, not %s."%
-                    type(oldvalues))
+        if not isinstance(oldvalues, type({})):
+            raise TypeError("'oldvalues' must be dict-like, not %s."%
+                type(oldvalues))
 
         cn = self.classname
         cl = self.db.classes[cn]
@@ -412,7 +567,7 @@ class IssueClass:
                 continue
             # not all keys from oldvalues might be available in database
             # this happens when property was deleted
-            try:                
+            try:
                 new_value = cl.get(nodeid, key)
             except KeyError:
                 continue
@@ -430,9 +585,9 @@ class IssueClass:
 
         # list the changes
         m = []
-        l = changed.items()
-        l.sort()
-        for propname, oldvalue in l:
+        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):
@@ -478,10 +633,20 @@ class IssueClass:
                     change += ' -%s'%(', '.join(l))
             else:
                 change = '%s -> %s'%(oldvalue, value)
+                if '\n' in change:
+                    value = self.indentChangeNoteValue(str(value))
+                    oldvalue = self.indentChangeNoteValue(str(oldvalue))
+                    change = _('\nNow:\n%(new)s\nWas:\n%(old)s') % {
+                        "new": value, "old": oldvalue}
             m.append('%s: %s'%(propname, change))
         if m:
             m.insert(0, '----------')
             m.insert(0, '')
         return '\n'.join(m)
 
-# vim: set filetype=python ts=4 sw=4 et si
+    def indentChangeNoteValue(self, text):
+        lines = text.rstrip('\n').split('\n')
+        lines = [ '  '+line for line in lines ]
+        return '\n'.join(lines)
+
+# vim: set filetype=python sts=4 sw=4 et si :
diff --git a/roundup/scripts/roundup_demo.py b/roundup/scripts/roundup_demo.py
new file mode 100644 (file)
index 0000000..29a5f0d
--- /dev/null
@@ -0,0 +1,44 @@
+#! /usr/bin/env python
+#
+# Copyright 2004 Richard Jones (richard@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 :
diff --git a/roundup/scripts/roundup_gettext.py b/roundup/scripts/roundup_gettext.py
new file mode 100644 (file)
index 0000000..6d72d59
--- /dev/null
@@ -0,0 +1,54 @@
+#! /usr/bin/env python
+#
+# Copyright 2004 Richard Jones (richard@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 :
index d9b4bbfcbad944b3736dc8609a9058af72d2ff4d..b2f10b88c973bfbdf0df71296f1bd38312eb4101 100644 (file)
@@ -13,8 +13,8 @@
 # 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.14 2004-04-13 04:14:03 richard Exp $
+#
+# $Id: roundup_mailgw.py,v 1.25 2008-08-19 01:06:01 richard Exp $
 
 """Command-line script stub that calls the roundup.mailgw.
 """
@@ -24,27 +24,28 @@ __docformat__ = 'restructuredtext'
 from roundup import version_check
 from roundup import __version__ as roundup_version
 
-import sys, os, re, cStringIO, getopt
+import sys, os, re, cStringIO, getopt, socket, netrc
 
-from roundup.mailgw import Message
+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 class] -S field=value]* <instance '
-        'home> [method]')%{'program': args[0]}
-    print _('''
+    print _(
+"""Usage: %(program)s [-v] [-c class] [[-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, or
- . with both an instance home and a POP/APOP server account.
+ . 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
@@ -66,16 +67,26 @@ UNIX mailbox:
  specified as:
    mailbox /path/to/mailbox
 
+In all of the following the username and password can be stored in a
+~/.netrc file. In this case only the server name need be specified on
+the command-line.
+
+The username and/or password will be prompted for if not supplied on
+the command-line or in ~/.netrc.
+
 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@server
The username and password may be omitted:
Alternatively, one can omit one or both of username and password:
     pop username@server
     pop server
- are both valid. The username and/or password will be prompted for if
- not supplied on the command-line.
+ are both valid.
+
+POPS:
+ Connect to a POP server over ssl. This requires python 2.4 or later.
+ This supports the same notation as POP.
 
 APOP:
  Same as POP, but using Authenticated POP:
@@ -88,13 +99,13 @@ IMAP:
  It also allows you to specify a specific mailbox other than INBOX using
  this format:
     imap username:password@server mailbox
+
 IMAPS:
- Connect to an IMAP server over ssl. 
+ Connect to an IMAP server over ssl.
  This supports the same notation as IMAP.
     imaps username:password@server [mailbox]
-''')
+
+""")%{'program': args[0]}
     return 1
 
 def main(argv):
@@ -103,7 +114,7 @@ def main(argv):
     # take the argv array and parse it leaving the non-option
     # arguments in the args array.
     try:
-        optionsList, args = getopt.getopt(argv[1:], 'vC:S:', ['set=',
+        optionsList, args = getopt.getopt(argv[1:], 'vc:C:S:', ['set=',
             'class='])
     except getopt.GetoptError:
         # print help information and exit:
@@ -120,7 +131,7 @@ def main(argv):
         instance_home = args[0]
     else:
         instance_home = os.environ.get('ROUNDUP_INSTANCE', '')
-    if not instance_home:
+    if not (instance_home and os.path.isdir(instance_home)):
         return usage(argv)
 
     # get the instance
@@ -132,7 +143,10 @@ def main(argv):
 
     # now wrap in try/finally so we always close the database
     try:
-        handler = instance.MailGW(instance, db, optionsList)
+        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:
@@ -142,45 +156,57 @@ def main(argv):
         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'))
+
+        # the source will be a network server, so obtain the credentials to
+        # use in connecting to the server
+        try:
+            # attempt to obtain credentials from a ~/.netrc file
+            authenticator = netrc.netrc().authenticators(specification)
+            username = authenticator[0]
+            password = authenticator[2]
+            server = specification
+            # IOError if no ~/.netrc file, TypeError if the hostname
+            # not found in the ~/.netrc file:
+        except (IOError, TypeError):
+            match = re.match(r'((?P<user>[^:]+)(:(?P<pass>.+))?@)?(?P<server>.+)',
+                             specification)
+            if match:
+                username = match.group('user')
+                password = match.group('pass')
+                server = match.group('server')
+            else:
+                return usage(argv, _('Error: %s specification not valid') % source)
+
+        # now invoke the mailgw handler depending on the server handler requested
+        if source.startswith('pop'):
+            ssl = source.endswith('s')
+            if ssl and sys.version_info<(2,4):
+                return usage(argv, _('Error: a later version of python is required'))
+            return handler.do_pop(server, username, password, ssl)
         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 = False
-                if source == 'imaps':
-                    ssl = True
-                mailbox = ''
-                if len(args) > 3:
-                    mailbox = args[3]
-                return handler.do_imap(m.group('server'), m.group('user'),
-                    m.group('pass'), mailbox, ssl)
+            return handler.do_apop(server, username, password)
+        elif source.startswith('imap'):
+            ssl = source.endswith('s')
+            mailbox = ''
+            if len(args) > 3:
+                mailbox = args[3]
+            return handler.do_imap(server, username, password, mailbox, ssl)
 
         return usage(argv, _('Error: The source must be either "mailbox",'
-            ' "pop", "apop", "imap" or "imaps"'))
+            ' "pop", "pops", "apop", "imap" or "imaps"'))
     finally:
-        db.close()
+        # handler might have closed the initial db and opened a new one
+        handler.db.close()
 
 def run():
-    # time out after a minute if we can
-    import socket
-    if hasattr(socket, 'setdefaulttimeout'):
-        socket.setdefaulttimeout(60)
     sys.exit(main(sys.argv))
 
 # call main
index 8afa8fdb9736e9d32bd804a6a3590838bcc9d86a..2eaa5ece03c7ebe328e9e8a36ca349588d18970f 100644 (file)
 # 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.45 2004-04-09 01:31:16 richard Exp $
+$Id: roundup_server.py,v 1.94 2007-09-25 04:27:12 jpend Exp $
 """
 __docformat__ = 'restructuredtext'
 
+import errno, cgi, getopt, os, socket, sys, traceback, urllib, time
+import ConfigParser, BaseHTTPServer, SocketServer, StringIO
+
+try:
+    from OpenSSL import SSL
+except ImportError:
+    SSL = None
+
 # python version check
-from roundup import version_check
+from roundup import configuration, version_check
 from roundup import __version__ as roundup_version
 
-import sys, os, urllib, StringIO, traceback, cgi, binascii, getopt, imp
-import SocketServer, BaseHTTPServer, socket, errno
-
 # Roundup modules of use here
 from roundup.cgi import cgitb, client
+from roundup.cgi.PageTemplates.PageTemplate import PageTemplate
 import roundup.instance
 from roundup.i18n import _
 
-try:
-    import signal
-except:
-    signal = None
-
-#
-##  Configuration
-#
-
-# This indicates where the Roundup trackers live. They're given as NAME ->
-# TRACKER_HOME, where the NAME part is used in the URL to select the
-# appropriate reacker.
-# Make sure the NAME part doesn't include any url-unsafe characters like 
-# spaces, as these confuse the cookie handling in browsers like IE.
-TRACKER_HOMES = {
-#    'example': '/path/to/example',
-}
-
-ROUNDUP_USER = None
-ROUNDUP_GROUP = None
-ROUNDUP_LOG_IP = 1
-HOSTNAME = ''
-PORT = 8080
-PIDFILE = None
-LOGFILE = None
-
-
-#
-##  end configuration
-#
-
 # "default" favicon.ico
 # generate by using "icotool" and tools/base64
 import zlib, base64
@@ -80,9 +55,128 @@ 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]
+
+def auto_ssl():
+    print _('WARNING: generating temporary SSL certificate')
+    import OpenSSL, time, random, sys
+    pkey = OpenSSL.crypto.PKey()
+    pkey.generate_key(OpenSSL.crypto.TYPE_RSA, 768)
+    cert = OpenSSL.crypto.X509()
+    cert.set_serial_number(random.randint(0, sys.maxint))
+    cert.gmtime_adj_notBefore(0)
+    cert.gmtime_adj_notAfter(60 * 60 * 24 * 365) # one year
+    cert.get_subject().CN = '*'
+    cert.get_subject().O = 'Roundup Dummy Certificate'
+    cert.get_issuer().CN = 'Roundup Dummy Certificate Authority'
+    cert.get_issuer().O = 'Self-Signed'
+    cert.set_pubkey(pkey)
+    cert.sign(pkey, 'md5')
+    ctx = SSL.Context(SSL.SSLv23_METHOD)
+    ctx.use_privatekey(pkey)
+    ctx.use_certificate(cert)
+
+    return ctx
+
+class SecureHTTPServer(BaseHTTPServer.HTTPServer):
+    def __init__(self, server_address, HandlerClass, ssl_pem=None):
+        assert SSL, "pyopenssl not installed"
+        BaseHTTPServer.HTTPServer.__init__(self, server_address, HandlerClass)
+        self.socket = socket.socket(self.address_family, self.socket_type)
+        if ssl_pem:
+            ctx = SSL.Context(SSL.SSLv23_METHOD)
+            ctx.use_privatekey_file(ssl_pem)
+            ctx.use_certificate_file(ssl_pem)
+        else:
+            ctx = auto_ssl()
+        self.ssl_context = ctx
+        self.socket = SSL.Connection(ctx, self.socket)
+        self.server_bind()
+        self.server_activate()
+
+    def get_request(self):
+        (conn, info) = self.socket.accept()
+        if self.ssl_context:
+
+            class RetryingFile(object):
+                """ SSL.Connection objects can return Want__Error
+                    on recv/write, meaning "try again". We'll handle
+                    the try looping here """
+                def __init__(self, fileobj):
+                    self.__fileobj = fileobj
+
+                def readline(self, *args):
+                    """ SSL.Connection can return WantRead """
+                    line = None
+                    while not line:
+                        try:
+                            line = self.__fileobj.readline(*args)
+                        except SSL.WantReadError:
+                            line = None
+                    return line
+
+                def __getattr__(self, attrib):
+                    return getattr(self.__fileobj, attrib)
+
+            class ConnFixer(object):
+                """ wraps an SSL socket so that it implements makefile
+                    which the HTTP handlers require """
+                def __init__(self, conn):
+                    self.__conn = conn
+                def makefile(self, mode, bufsize):
+                    fo = socket._fileobject(self.__conn, mode, bufsize)
+                    return RetryingFile(fo)
+
+                def __getattr__(self, attrib):
+                    return getattr(self.__conn, attrib)
+
+            conn = ConnFixer(conn)
+        return (conn, info)
+
 class RoundupRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
-    TRACKER_HOMES = TRACKER_HOMES
-    ROUNDUP_USER = ROUNDUP_USER
+    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
@@ -94,8 +188,8 @@ class RoundupRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
             self.inner_run_cgi()
         except client.NotFound:
             self.send_error(404, self.path)
-        except client.Unauthorised:
-            self.send_error(403, 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):
@@ -106,47 +200,103 @@ class RoundupRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
                 self.send_response(400)
                 self.send_header('Content-Type', 'text/html')
                 self.end_headers()
-                try:
-                    reload(cgitb)
+                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())
-                    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")
+                    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 = run_cgi
+    do_GET = do_POST = do_HEAD = run_cgi
 
     def index(self):
         ''' Print up an index of the available trackers
         '''
-        self.send_response(200)
+        keys = self.TRACKER_HOMES.keys()
+        if len(keys) == 1:
+            self.send_response(302)
+            self.send_header('Location', urllib.quote(keys[0]) + '/index')
+            self.end_headers()
+        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'))
-        w(_('<body><h1>Roundup trackers index</h1><ol>\n'))
-        keys = self.TRACKER_HOMES.keys()
-        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>'))
+
+        if self.CONFIG and self.CONFIG['TEMPLATE']:
+            template = open(self.CONFIG['TEMPLATE']).read()
+            pt = PageTemplate()
+            pt.write(template)
+            extra = { 'trackers': self.TRACKERS,
+                'nothing' : None,
+                'true' : 1,
+                'false' : 0,
+            }
+            w(pt.pt_render(extra_context=extra))
+        else:
+            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()
-            self.wfile.write(favico)
+
+            # 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('?')
@@ -157,11 +307,12 @@ class RoundupRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
 
         # no tracker - spit out the index
         if rest == '/':
-            return self.index()
+            self.index()
+            return
 
         # figure the tracker
         l_path = rest.split('/')
-        tracker_name = urllib.unquote(l_path[1])
+        tracker_name = urllib.unquote(l_path[1]).lower()
 
         # handle missing trailing '/'
         if len(l_path) == 2:
@@ -174,12 +325,6 @@ class RoundupRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
             self.wfile.write('Moved Permanently')
             return
 
-        if self.TRACKER_HOMES.has_key(tracker_name):
-            tracker_home = self.TRACKER_HOMES[tracker_name]
-            tracker = roundup.instance.open(tracker_home)
-        else:
-            raise client.NotFound
-
         # figure out what the rest of the path is
         if len(l_path) > 2:
             rest = '/'.join(l_path[2:])
@@ -193,7 +338,6 @@ class RoundupRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
         env['PATH_INFO'] = urllib.unquote(rest)
         if query:
             env['QUERY_STRING'] = query
-        host = self.address_string()
         if self.headers.typeheader is None:
             env['CONTENT_TYPE'] = self.headers.type
         else:
@@ -209,14 +353,14 @@ class RoundupRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
         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')
 
-        decoded_query = query.replace('+', ' ')
+        # do the roundup thing
+        tracker = self.get_tracker(tracker_name)
+        tracker.Client(tracker, self, env).main()
 
-        # do the roundup thang
-        c = tracker.Client(tracker, self, env)
-        c.main()
-
-    LOG_IPADDRESS = ROUNDUP_LOG_IP
     def address_string(self):
         if self.LOG_IPADDRESS:
             return self.client_address[0]
@@ -234,145 +378,395 @@ class RoundupRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
             # stderr is no longer viable
             pass
 
+    def start_response(self, headers, response):
+        self.send_response(response)
+        for key, value in headers:
+            self.send_header(key, value)
+        self.end_headers()
+
 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')
+        return
+
+    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, "nodaemon", "no",
+                "don't fork (this overrides the pidfile mechanism)'"),
+            (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)),
+            (configuration.NullableFilePathOption, "template", "",
+                "Tracker index template. If unset, built-in will be used."),
+            (configuration.BooleanOption, "ssl", "no",
+                "Enable SSL support (requires pyopenssl)"),
+            (configuration.NullableFilePathOption, "pem", "",
+                "PEM file used for SSL. A temporary self-signed certificate\n"
+                "will be used if left blank."),
+        )),
+        ("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:",
+        "nodaemon": "D",
+        "log_hostnames": "N",
+        "multiprocess": "t:",
+        "template": "i:",
+        "ssl": "s",
+        "pem": "e:",
+    }
+
+    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
+
+        if self["SSL"]:
+            base_server = SecureHTTPServer
+        else:
+            base_server = BaseHTTPServer.HTTPServer
+
+        # 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 = base_server
+        elif self["MULTIPROCESS"] == "fork":
+            class ForkingServer(SocketServer.ForkingMixIn,
+                base_server):
+                    pass
+            server_class = ForkingServer
+        elif self["MULTIPROCESS"] == "thread":
+            class ThreadingServer(SocketServer.ThreadingMixIn,
+                base_server):
+                    pass
+            server_class = ThreadingServer
+        else:
+            server_class = base_server
+
+        # obtain server before changing user id - allows to
+        # use port < 1024 if started as root
+        try:
+            args = ((self["HOST"], self["PORT"]), RequestHandler)
+            kwargs = {}
+            if self["SSL"]:
+                kwargs['ssl_pem'] = self["PEM"]
+            httpd = server_class(*args, **kwargs)
+        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
-    import win32event
-    from win32event import *
-    from win32file import *
 
-    SvcShutdown = "ServiceShutdown"
+    class SvcShutdown(Exception):
+        pass
 
-    class RoundupService(win32serviceutil.ServiceFramework,
-            BaseHTTPServer.HTTPServer):
-        ''' A Roundup standalone server for Win32 by Ewout Prangsma
-        '''
-        _svc_name_ = "Roundup Bug Tracker"
+    class RoundupService(win32serviceutil.ServiceFramework):
+
+        _svc_name_ = "roundup"
         _svc_display_name_ = "Roundup Bug Tracker"
-        address = (HOSTNAME, PORT)
-        def __init__(self, args):
-            # redirect stdout/stderr to our logfile
-            if LOGFILE:
-                # appending, unbuffered
-                sys.stdout = sys.stderr = open(LOGFILE, 'a', 0)
-            win32serviceutil.ServiceFramework.__init__(self, args)
-            BaseHTTPServer.HTTPServer.__init__(self, self.address, 
-                RoundupRequestHandler)
-
-            # Create the necessary NT Event synchronization objects...
-            # hevSvcStop is signaled when the SCM sends us a notification
-            # to shutdown the service.
-            self.hevSvcStop = win32event.CreateEvent(None, 0, 0, None)
-
-            # hevConn is signaled when we have a new incomming connection.
-            self.hevConn    = win32event.CreateEvent(None, 0, 0, None)
-
-            # Hang onto this module for other people to use for logging
-            # purposes.
-            import servicemanager
-            self.servicemanager = servicemanager
 
-        def SvcStop(self):
-            # Before we do anything, tell the SCM we are starting the
-            # stop process.
-            self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
-            win32event.SetEvent(self.hevSvcStop)
+        running = 0
+        server = None
 
         def SvcDoRun(self):
-            try:
-                self.serve_forever()
-            except SvcShutdown:
-                pass
+            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 get_request(self):
-            # Call WSAEventSelect to enable self.socket to be waited on.
-            WSAEventSelect(self.socket, self.hevConn, FD_ACCEPT)
-            while 1:
-                try:
-                    rv = self.socket.accept()
-                except socket.error, why:
-                    if why[0] != WSAEWOULDBLOCK:
-                        raise
-                    # Use WaitForMultipleObjects instead of select() because
-                    # on NT select() is only good for sockets, and not general
-                    # NT synchronization objects.
-                    rc = WaitForMultipleObjects((self.hevSvcStop, self.hevConn),
-                        0, INFINITE)
-                    if rc == WAIT_OBJECT_0:
-                        # self.hevSvcStop was signaled, this means:
-                        # Stop the service!
-                        # So we throw the shutdown exception, which gets
-                        # caught by self.SvcDoRun
-                        raise SvcShutdown
-                    # Otherwise, rc == WAIT_OBJECT_0 + 1 which means
-                    # self.hevConn was signaled, which means when we call 
-                    # self.socket.accept(), we'll have our incoming connection
-                    # socket!
-                    # Loop back to the top, and let that accept do its thing...
-                else:
-                    # yay! we have a connection
-                    # However... the new socket is non-blocking, we need to
-                    # set it back into blocking mode. (The socket that accept()
-                    # returns has the same properties as the listening sockets,
-                    # this includes any properties set by WSAAsyncSelect, or 
-                    # WSAEventSelect, and whether its a blocking socket or not.)
-                    #
-                    # So if you yank the following line, the setblocking() call 
-                    # will be useless. The socket will still be in non-blocking
-                    # mode.
-                    WSAEventSelect(rv[0], self.hevConn, 0)
-                    rv[0].setblocking(1)
-                    break
-            return rv
+        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:
-        win = ''' -c: Windows Service options.  If you want to run the server as a Windows
-     Service, you must configure the rest of the options by changing the
-     constants of this program.  You will at least configure one tracker
-     in the TRACKER_HOMES variable.  This option is mutually exclusive
-     from the rest.  Typing "roundup-server -c help" shows Windows
-     Services specifics.'''
+        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:
-        win = ''
-    port=PORT
-    print _('''%(message)s
-Usage:
-roundup-server [options] [name=tracker home]*
-
-options:
- -v: print version and exit
- -n: sets the host name
- -p: sets the port to listen on (default: %(port)s)
- -u: sets the uid to this user after listening on the port
- -g: sets the gid to this group after listening on the port
- -l: sets a filename to log to (instead of stderr / stdout)
- -d: run the server in the background and on UN*X write the server's PID
-     to the nominated file. The -l option *must* be specified if this
-     option is.
- -N: log client machine names in access log instead of IP addresses (much
-     slower)
-%(win)s
-
-name=tracker home:
-   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
+        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)
+ -i <fname>    set tracker index template
+ -s            enable SSL
+ -e <fname>    PEM file containing SSL key and certificate
+ -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. 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.
-''')%locals()
-    sys.exit(0)
+   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 writepidfile(pidfile):
+    ''' Write a pidfile (only). Do not daemonize. '''
+    pid = os.getpid()
+    if pid:
+        pidfile = open(pidfile, 'w')
+        pidfile.write(str(pid))
+        pidfile.close()
 
 def daemonize(pidfile):
     ''' Turn this process into a daemon.
@@ -400,154 +794,118 @@ def daemonize(pidfile):
         os._exit(0)
 
     os.chdir("/")
-    os.umask(0)
 
-    # close off sys.std(in|out|err), redirect to devnull so the file
+    # 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)
 
-def run(port=PORT, success_message=None):
+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
-    import socket
     if hasattr(socket, 'setdefaulttimeout'):
         socket.setdefaulttimeout(60)
 
-    hostname = HOSTNAME
-    pidfile = PIDFILE
-    logfile = LOGFILE
-    user = ROUNDUP_USER
-    group = ROUNDUP_GROUP
-    svc_args = None
-
+    config = ServerConfig()
+    # additional options
+    short_options = "hvS"
+    if RoundupService:
+        short_options += 'c'
     try:
-        # handle the command-line args
-        options = 'n:p:u:d:l:hNv'
-        if RoundupService:
-            options += 'c'
-
-        try:
-            optlist, args = getopt.getopt(sys.argv[1:], options)
-        except getopt.GetoptError, e:
-            usage(str(e))
-
-        user = ROUNDUP_USER
-        group = None
+        (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 == '-n': hostname = arg
-            elif opt == '-v':
-                print '%s (python %s)'%(roundup_version, sys.version.split()[0])
-                return
-            elif opt == '-p': port = int(arg)
-            elif opt == '-u': user = arg
-            elif opt == '-g': group = arg
-            elif opt == '-d': pidfile = os.path.abspath(arg)
-            elif opt == '-l': logfile = os.path.abspath(arg)
-            elif opt == '-h': usage()
-            elif opt == '-N': RoundupRequestHandler.LOG_IPADDRESS = 0
-            elif opt == '-c': svc_args = [opt] + args; args = None
-
-        if svc_args is not None and len(optlist) > 1:
-            raise ValueError, _("windows service option must be the only one")
-
-        if pidfile and not logfile:
-            raise ValueError, _("logfile *must* be specified if pidfile is")
-  
-        # obtain server before changing user id - allows to use port <
-        # 1024 if started as root
-        address = (hostname, port)
-        server_klass = BaseHTTPServer.HTTPServer
-        class server_klass(SocketServer.ForkingMixIn,
-                BaseHTTPServer.HTTPServer):
-            pass
-        try:
-            httpd = server_klass(address, RoundupRequestHandler)
-        except socket.error, e:
-            if e[0] == errno.EADDRINUSE:
-                raise socket.error, \
-                      _("Unable to bind to port %s, port already in use." % port)
-            raise
-
-        if group is not None and hasattr(os, 'getgid'):
-            # if root, setgid to the running user
-            if not os.getgid() and user is not None:
-                try:
-                    import pwd
-                except ImportError:
-                    raise ValueError, _("Can't change groups - no pwd module")
-                try:
-                    gid = pwd.getpwnam(user)[3]
-                except KeyError:
-                    raise ValueError,_("Group %(group)s doesn't exist")%locals()
-                os.setgid(gid)
-            elif os.getgid() and user is not None:
-                print _('WARNING: ignoring "-g" argument, not root')
-
-        if hasattr(os, 'getuid'):
-            # if root, setuid to the running user
-            if not os.getuid() and user is not None:
-                try:
-                    import pwd
-                except ImportError:
-                    raise ValueError, _("Can't change users - no pwd module")
-                try:
-                    uid = pwd.getpwnam(user)[2]
-                except KeyError:
-                    raise ValueError, _("User %(user)s doesn't exist")%locals()
-                os.setuid(uid)
-            elif os.getuid() and user is not None:
-                print _('WARNING: ignoring "-u" argument, not root')
-
-            # People can remove this check if they're really determined
-            if not os.getuid() and user is None:
-                raise ValueError, _("Can't run as root!")
-
-        # handle tracker specs
-        if args:
-            d = {}
-            for arg in args:
-                try:
-                    name, home = arg.split('=')
-                except ValueError:
-                    raise ValueError, _("Instances must be name=home")
-                d[name] = os.path.abspath(home)
-            RoundupRequestHandler.TRACKER_HOMES = d
-    except SystemExit:
-        raise
-    except ValueError:
-        usage(error())
-    except:
-        print error()
-        sys.exit(1)
-
-    # we don't want the cgi module interpreting the command-line args ;)
-    sys.argv = sys.argv[:1]
-
-    if pidfile:
+            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'
+            print _("Sorry, you can't run the server as a daemon"
+                " on this Operating System")
             sys.exit(0)
         else:
-            daemonize(pidfile)
-
-    if svc_args is not None:
-        # don't do any other stuff
-        return win32serviceutil.HandleCommandLine(RoundupService, argv=svc_args)
+            if config['NODAEMON']:
+                writepidfile(config["PIDFILE"])
+            else:
+                daemonize(config["PIDFILE"])
 
-    # redirect stdout/stderr to our logfile
-    if logfile:
-        # appending, unbuffered
-        sys.stdout = sys.stderr = open(logfile, 'a', 0)
+    # create the server
+    httpd = config.get_server()
 
     if success_message:
         print success_message
     else:
-        print _('Roundup server started on %(address)s')%locals()
+        print _('Roundup server started on %(HOST)s:%(PORT)s') \
+            % config
 
     try:
         httpd.serve_forever()
@@ -557,4 +915,4 @@ def run(port=PORT, success_message=None):
 if __name__ == '__main__':
     run()
 
-# vim: set filetype=python ts=4 sw=4 et si
+# vim: sts=4 sw=4 et si
diff --git a/roundup/scripts/roundup_xmlrpc_server.py b/roundup/scripts/roundup_xmlrpc_server.py
new file mode 100644 (file)
index 0000000..020cc38
--- /dev/null
@@ -0,0 +1,75 @@
+#! /usr/bin/env python
+#
+# Copyright (C) 2007 Stefan Seefeld
+# All rights reserved.
+# For license terms see the file COPYING.txt.
+#
+
+import getopt, os, sys, socket
+from roundup.xmlrpc import RoundupServer, RoundupRequestHandler
+from roundup.instance import TrackerError
+from SimpleXMLRPCServer import SimpleXMLRPCServer
+
+def usage():
+    print """
+
+Options:
+ -i instance home  -- specify the issue tracker "home directory" to administer
+ -V                -- be verbose when importing
+ -p, --port <port> -- port to listen on
+
+"""
+
+def run():
+
+    try:
+        opts, args = getopt.getopt(sys.argv[1:],
+                                   'e:i:p:V', ['encoding=', 'port='])
+    except getopt.GetoptError, e:
+        usage()
+        return 1
+
+    verbose = False
+    tracker = ''
+    port = 8000
+    encoding = None
+
+    for opt, arg in opts:
+        if opt == '-V':
+            verbose = True
+        elif opt == '-i':
+            tracker = arg
+        elif opt in ['-p', '--port']:
+            port = int(arg)
+        elif opt in ['-e', '--encoding']:
+            encoding = encoding
+
+        if sys.version_info[0:2] < (2,5):
+            if encoding:
+                print 'encodings not supported with python < 2.5'
+                sys.exit(-1)
+            server = SimpleXMLRPCServer(('', port), RoundupRequestHandler)
+        else:
+            server = SimpleXMLRPCServer(('', port), RoundupRequestHandler,
+                                        allow_none=True, encoding=encoding)
+    if not os.path.exists(tracker):
+        print 'Instance home does not exist.'
+        sys.exit(-1)
+    try:
+        object = RoundupServer(tracker, verbose)
+    except TrackerError:
+        print 'Instance home does not exist.'
+        sys.exit(-1)
+
+    server.register_instance(object)
+
+    # Go into the main listener loop
+    print 'Roundup XMLRPC server started on %s:%d' \
+          % (socket.gethostname(), port)
+    try:
+        server.serve_forever()
+    except KeyboardInterrupt:
+        print 'Keyboard Interrupt: exiting'
+
+if __name__ == '__main__':
+    run()
index 58496f452a18d35648ecb9886c4dfbaa986cce83..2a1e520caede8f84eaa53494afc8246bfba43102 100644 (file)
@@ -4,25 +4,70 @@ __docformat__ = 'restructuredtext'
 
 import weakref
 
-from roundup import hyperdb
+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):
+    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>'%(id(self), self.name, self.klass)
+        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
@@ -58,16 +103,15 @@ class Security:
         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)
-        reg = self.addPermission(name="Register Web",
-            description="User may register through the web")
-        reg = self.addPermission(name="Register Email",
-            description="User may register through the email")
 
         # initialise the permissions and roles needed for the UIs
         from roundup.cgi import client
@@ -75,7 +119,8 @@ class Security:
         from roundup import mailgw
         mailgw.initialiseSecurity(self)
 
-    def getPermission(self, permission, classname=None):
+    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.
 
@@ -84,64 +129,63 @@ class Security:
         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 we're passed a classname, the permission must match
-            if perm.klass is not None and perm.klass == classname:
-                return perm
-            # otherwise the permission klass must be unset
-            elif not perm.klass and not classname:
+            if perm == tester:
                 return perm
         raise ValueError, 'No permission "%s" defined for "%s"'%(permission,
             classname)
 
-    def hasPermission(self, permission, userid, classname=None):
-        ''' Look through all the Roles, and hence Permissions, and see if
-            "permission" is there for the specified 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 name match?
-                if perm.name == permission:
-                    # permission klass match?
-                    if perm.klass is None or perm.klass == classname:
-                        # we have a winner
-                        return 1
+                # permission match?
+                if perm.test(self.db, permission, classname, property,
+                        userid, itemid):
+                    return 1
         return 0
 
-    def hasNodePermission(self, classname, nodeid, **propspec):
-        ''' Check the named properties of the given node to see if the
-            userid appears in them. If it does, then the user is granted
-            this permission check.
-
-            'propspec' consists of a set of properties and values that
-            must be present on the given node for access to be granted.
-
-            If a property is a Link, the value must match the property
-            value. If a property is a Multilink, the value must appear
-            in the Multilink list.
-        '''
-        klass = self.db.getclass(classname)
-        properties = klass.getprops()
-        for k,v in propspec.items():
-            value = klass.get(nodeid, k)
-            if isinstance(properties[k], hyperdb.Multilink):
-                if v not in value:
-                    return 0
-            else:
-                if v != value:
-                    return 0
-        return 1
-
     def addPermission(self, **propspec):
         ''' Create a new Permission with the properties defined in
-            'propspec'
+            'propspec'. See the Permission class for the possible
+            keyword args.
         '''
         perm = Permission(**propspec)
         self.permission.setdefault(perm.name, []).append(perm)
@@ -154,12 +198,21 @@ class Security:
         self.role[role.name] = role
         return role
 
-    def addPermissionToRole(self, rolename, permission):
+    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 ts=4 sw=4 et si
+# vim: set filetype=python sts=4 sw=4 et si :
diff --git a/roundup/support.py b/roundup/support.py
new file mode 100644 (file)
index 0000000..cdd97fc
--- /dev/null
@@ -0,0 +1,248 @@
+"""Implements various support classes and functions used in a number of
+places in Roundup code.
+"""
+
+__docformat__ = 'restructuredtext'
+
+import os, time, sys, re
+
+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()
+
+LEFT = 'left'
+LEFTN = 'left no strip'
+RIGHT = 'right'
+CENTER = 'center'
+
+def align(line, width=70, alignment=LEFTN):
+    ''' Code from http://www.faqts.com/knowledge_base/view.phtml/aid/4476 '''
+    if alignment == CENTER:
+        line = line.strip()
+        space = width - len(line)
+        return ' '*(space/2) + line + ' '*(space/2 + space%2)
+    elif alignment == RIGHT:
+        line = line.rstrip()
+        space = width - len(line)
+        return ' '*space + line
+    else:
+        if alignment == LEFT:
+            line = line.lstrip()
+        space = width - len(line)
+        return line + ' '*space
+
+
+def format_line(columns, positions, contents, spacer=' | ',
+        collapse_whitespace=True, wsre=re.compile(r'\s+')):
+    ''' Fill up a single row with data from the contents '''
+    l = []
+    data = 0
+    for i in range(len(columns)):
+        width, alignment = columns[i]
+        content = contents[i]
+        col = ''
+        while positions[i] < len(content):
+            word = content[positions[i]]
+            # if we hit a newline, honor it
+            if '\n' in word:
+                # chomp
+                positions[i] += 1
+                break
+
+            # make sure this word fits
+            if col and len(word) + len(col) > width:
+                break
+
+            # no whitespace at start-of-line
+            if collapse_whitespace and wsre.match(word) and not col:
+                # chomp
+                positions[i] += 1
+                continue
+
+            col += word
+            # chomp
+            positions[i] += 1
+        if col:
+            data = 1
+        col = align(col, width, alignment)
+        l.append(col)
+
+    if not data:
+        return ''
+    return spacer.join(l).rstrip()
+
+
+def format_columns(columns, contents, spacer=' | ', collapse_whitespace=True,
+        splitre=re.compile(r'(\n|\r\n|\r|[ \t]+|\S+)')):
+    ''' Format the contents into columns, with 'spacing' between the
+        columns
+    '''
+    assert len(columns) == len(contents), \
+        'columns and contents must be same length'
+
+    # split the text into words, spaces/tabs and newlines
+    for i in range(len(contents)):
+        contents[i] = splitre.findall(contents[i])
+
+    # now process line by line
+    l = []
+    positions = [0]*len(contents)
+    while 1:
+        l.append(format_line(columns, positions, contents, spacer,
+            collapse_whitespace))
+
+        # are we done?
+        for i in range(len(contents)):
+            if positions[i] < len(contents[i]):
+                break
+        else:
+            break
+    return '\n'.join(l)
+
+def wrap(text, width=75, alignment=LEFTN):
+    return format_columns(((width, alignment),), [text],
+        collapse_whitespace=False)
+
+# Python2.3 backwards-compatibility-hack. Should be removed (and clients
+# fixed to use built-in reversed/sorted) when we abandon support for
+# python2.3
+try:
+    reversed = reversed
+except NameError:
+    def reversed(x):
+        x = list(x)
+        x.reverse()
+        return x
+
+try:
+    sorted = sorted
+except NameError:
+    def sorted(iter, cmp=None, key=None, reverse=False):
+        if key:
+            l = []
+            cnt = 0 # cnt preserves original sort-order
+            inc = [1, -1][bool(reverse)] # count down on reverse
+            for x in iter:
+                l.append ((key(x), cnt, x))
+                cnt += inc
+        else:
+            l = list(iter)
+        if cmp:
+            l.sort(cmp = cmp)
+        else:
+            l.sort()
+        if reverse:
+            l.reverse()
+        if key:
+            return [x[-1] for x in l]
+        return l
+
+# vim: set et sts=4 sw=4 :
diff --git a/roundup/xmlrpc.py b/roundup/xmlrpc.py
new file mode 100644 (file)
index 0000000..c3eaccd
--- /dev/null
@@ -0,0 +1,198 @@
+#
+# Copyright (C) 2007 Stefan Seefeld
+# All rights reserved.
+# For license terms see the file COPYING.txt.
+#
+
+import base64
+import roundup.instance
+from roundup import hyperdb
+from roundup.cgi.exceptions import *
+from roundup.admin import UsageError
+from SimpleXMLRPCServer import SimpleXMLRPCRequestHandler
+
+class RoundupRequestHandler(SimpleXMLRPCRequestHandler):
+    """A SimpleXMLRPCRequestHandler with support for basic
+    HTTP Authentication."""
+
+    def do_POST(self):
+        """Extract username and password from authorization header."""
+
+        # Try to extract username and password from HTTP Authentication.
+        self.username = None
+        self.password = None
+        authorization = self.headers.get('authorization', ' ')
+        scheme, challenge = authorization.split(' ', 1)
+
+        if scheme.lower() == 'basic':
+            decoded = base64.decodestring(challenge)
+            self.username, self.password = decoded.split(':')
+
+        SimpleXMLRPCRequestHandler.do_POST(self)
+
+    def _dispatch(self, method, params):
+        """Inject username and password into function arguments."""
+
+        # Add username and password to function arguments
+        params = [self.username, self.password] + list(params)
+        return self.server._dispatch(method, params)
+
+
+class RoundupRequest:
+    """Little helper class to handle common per-request tasks such
+    as authentication and login."""
+
+    def __init__(self, tracker, username, password):
+        """Open the database for the given tracker, using the given
+        username and password."""
+
+        self.tracker = tracker
+        self.db = self.tracker.open('admin')
+        try:
+            self.userid = self.db.user.lookup(username)
+        except KeyError: # No such user
+            self.db.close()
+            raise Unauthorised, 'Invalid user'
+        stored = self.db.user.get(self.userid, 'password')
+        if stored != password:
+            # Wrong password
+            self.db.close()
+            raise Unauthorised, 'Invalid user'
+        self.db.setCurrentUser(username)
+
+    def close(self):
+        """Close the database, after committing any changes, if needed."""
+
+        try:
+            self.db.commit()
+        finally:
+            self.db.close()
+
+    def get_class(self, classname):
+        """Return the class for the given classname."""
+
+        try:
+            return self.db.getclass(classname)
+        except KeyError:
+            raise UsageError, 'no such class "%s"'%classname
+
+    def props_from_args(self, cl, args, itemid=None):
+        """Construct a list of properties from the given arguments,
+        and return them after validation."""
+
+        props = {}
+        for arg in args:
+            if arg.find('=') == -1:
+                raise UsageError, 'argument "%s" not propname=value'%arg
+            l = arg.split('=')
+            if len(l) < 2:
+                raise UsageError, 'argument "%s" not propname=value'%arg
+            key, value = l[0], '='.join(l[1:])
+            if value:
+                try:
+                    props[key] = hyperdb.rawToHyperdb(self.db, cl, itemid,
+                        key, value)
+                except hyperdb.HyperdbValueError, message:
+                    raise UsageError, message
+            else:
+                props[key] = None
+
+        return props
+
+
+#The server object
+class RoundupServer:
+    """The RoundupServer provides the interface accessible through
+    the Python XMLRPC mapping. All methods take an additional username
+    and password argument so each request can be authenticated."""
+
+    def __init__(self, tracker, verbose = False):
+        self.tracker = roundup.instance.open(tracker)
+        self.verbose = verbose
+
+    def list(self, username, password, classname, propname=None):
+        r = RoundupRequest(self.tracker, username, password)
+        try:
+            cl = r.get_class(classname)
+            if not propname:
+                propname = cl.labelprop()
+            result = [cl.get(itemid, propname)
+                for itemid in cl.list()
+                     if r.db.security.hasPermission('View', r.userid,
+                         classname, propname, itemid)
+            ]
+        finally:
+            r.close()
+        return result
+
+    def filter(self, username, password, classname, search_matches, filterspec,
+            sort=[], group=[]):
+        r = RoundupRequest(self.tracker, username, password)
+        try:
+            cl = r.get_class(classname)
+            result = cl.filter(search_matches, filterspec, sort=sort, group=group)
+        finally:
+            r.close()
+        return result
+
+    def display(self, username, password, designator, *properties):
+        r = RoundupRequest(self.tracker, username, password)
+        try:
+            classname, itemid = hyperdb.splitDesignator(designator)
+            cl = r.get_class(classname)
+            props = properties and list(properties) or cl.properties.keys()
+            props.sort()
+            for p in props:
+                if not r.db.security.hasPermission('View', r.userid,
+                        classname, p, itemid):
+                    raise Unauthorised('Permission to view %s of %s denied'%
+                            (p, designator))
+            result = [(prop, cl.get(itemid, prop)) for prop in props]
+        finally:
+            r.close()
+        return dict(result)
+
+    def create(self, username, password, classname, *args):
+        r = RoundupRequest(self.tracker, username, password)
+        try:
+            if not r.db.security.hasPermission('Create', r.userid, classname):
+                raise Unauthorised('Permission to create %s denied'%classname)
+
+            cl = r.get_class(classname)
+
+            # convert types
+            props = r.props_from_args(cl, args)
+
+            # check for the key property
+            key = cl.getkey()
+            if key and not props.has_key(key):
+                raise UsageError, 'you must provide the "%s" property.'%key
+
+            # do the actual create
+            try:
+                result = cl.create(**props)
+            except (TypeError, IndexError, ValueError), message:
+                raise UsageError, message
+        finally:
+            r.close()
+        return result
+
+    def set(self, username, password, designator, *args):
+        r = RoundupRequest(self.tracker, username, password)
+        try:
+            classname, itemid = hyperdb.splitDesignator(designator)
+            cl = r.get_class(classname)
+            props = r.props_from_args(cl, args, itemid) # convert types
+            for p in props.iterkeys ():
+                if not r.db.security.hasPermission('Edit', r.userid,
+                        classname, p, itemid):
+                    raise Unauthorised('Permission to edit %s of %s denied'%
+                        (p, designator))
+            try:
+                return cl.set(itemid, **props)
+            except (TypeError, IndexError, ValueError), message:
+                raise UsageError, message
+        finally:
+            r.close()
+
+# vim: set et sts=4 sw=4 :
index 2cb85a6ddb69b18fb59cad2359c67d750182af6b..c54375307b4a3a055751d0708a1ec52efc0b3009 100644 (file)
@@ -1,4 +1,4 @@
-#! /usr/bin/env python2.2
+#! /usr/bin/env python
 ##############################################################################
 #
 # Copyright (c) 2001, 2002 Zope Corporation and Contributors.
@@ -184,6 +184,8 @@ class ImmediateTestResult(unittest._TextTestResult):
         self._progressWithNames = 0
         self._count = count
         self._testtimes = {}
+        # docstrings for tests don't override test-descriptions:
+        self.descriptions = False
         if progress and verbosity == 1:
             self.dots = 0
             self._progressWithNames = 1
@@ -696,7 +698,7 @@ def process_args(argv=None):
 
     module_filter = None
     test_filter = None
-    VERBOSE = 1
+    VERBOSE = 2
     LOOP = 0
     GUI = 0
     TRACE = 0
index a83a7223cdd0298f5131c26fcdb33389c3131606..c599c6760621234e24ac205826c7f76a8519ff18 100644 (file)
@@ -8,6 +8,10 @@ roundup-reminder
  Generate an email that lists outstanding issues. Send in both plain text
  and HTML formats.
 
+weekly-report
+ Generate a simple report outlining the activity in one tracker for the
+ most recent week.
+
 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
@@ -23,3 +27,7 @@ roundup.rc-debian
  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.
+
diff --git a/scripts/hyperdb_example.py b/scripts/hyperdb_example.py
new file mode 100644 (file)
index 0000000..ca7f2b6
--- /dev/null
@@ -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(
diff --git a/scripts/imapServer.py b/scripts/imapServer.py
new file mode 100644 (file)
index 0000000..7ebda75
--- /dev/null
@@ -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@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@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
diff --git a/scripts/import_sf.py b/scripts/import_sf.py
new file mode 100644 (file)
index 0000000..c5ab933
--- /dev/null
@@ -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@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@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['keyword'] = [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:])
+
index 69a064a6739c36752d279f30bf8fda1049bd09ed..0e98a91e8856566a75de756944bc086a5d1e05f0 100755 (executable)
@@ -19,7 +19,7 @@
 # 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 $
+# $Id: roundup-reminder,v 1.9 2007-02-15 03:52:35 richard Exp $
 
 '''
 Simple script that emails all users of a tracker with the issues that
@@ -96,7 +96,7 @@ for user_id in db.user.list():
     body = part.startbody('text/plain')
     
     # do the plain text bit
-    print >>body, 'Created     ID   Urgency   Title'
+    print >>body, 'Created     ID   Activity  Title'
     print >>body, '='*75
     #             '2 months    213  immediate cc_daemon barfage
     old_priority = None
@@ -106,10 +106,10 @@ for user_id in db.user.list():
             old_priority = priority
             print >>body, '    ', db.priority.get(priority,'name')
         # pretty creation
-        creation = (date.Date('.') - creation_date).pretty()
+        creation = (creation_date - date.Date('.')).pretty()
         if creation is None:
             creation = creation_date.pretty()
-        activity = (date.Date('.') - activity_date).pretty()
+        activity = (activity_date - date.Date('.')).pretty()
         title = db.issue.get(issue_id, 'title')
         if len(title) > 42:
             title = title[:38] + ' ...'
@@ -147,11 +147,11 @@ and click on "My Issues". Do NOT respond to this message.
            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()
+            creation = (creation_date - date.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()
+        activity = (activity_date - date.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>'
index 61ebf97a7063e90d2b77426b73989f01f67c9f5b..57265c8c1837b0802f584f91d865322f6915fc2e 100755 (executable)
@@ -2,20 +2,23 @@
 
 #
 # Configuration
-# 
-PORT=8080
-PIDFILE="/usr/local/roundup/var/roundup-server.pid"
-LOGFILE="/usr/local/roundup/var/roundup-server.log"
-OTHERARGS=""
-TRACKERS="cg=/usr/local/roundup/trackers/cg"
+#
+CONFFILE="/var/roundup/server-config.ini"
 
-SERVER="/usr/local/bin/roundup-server -l ${LOGFILE} -d ${PIDFILE} -p ${PORT} ${OTHERARGS} ${TRACKERS}"
+# 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 
+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
diff --git a/scripts/weekly-report b/scripts/weekly-report
new file mode 100755 (executable)
index 0000000..102e1e4
--- /dev/null
@@ -0,0 +1,58 @@
+#! /usr/bin/env python2.3
+
+# This script generates a simple report outlining the activity in one
+# tracker for the most recent week.
+
+# This script is free software, you may redistribute it
+# and/or modify under the same terms as Python.
+
+import sys, math
+from roundup import instance, date
+
+# 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')
+
+old = date.Date('-1w')
+
+created = []
+summary = {}
+messages = []
+
+# loop through all the recently-active issues
+for issue_id in db.issue.filter(None, {'activity': '-1w;'}):
+    num = 0
+    for x,ts,userid,action,data in db.issue.history(issue_id):
+        if ts < old: continue
+        if action == 'create':
+            created.append(issue_id)
+        elif action == 'set' and data.has_key('messages'):
+            num += 1
+    summary.setdefault(db.issue.get(issue_id, 'status'), []).append(issue_id)
+    messages.append((num, issue_id))
+
+#print 'STATUS SUMMARY:'
+#for k,v in summary.items():
+#    print k, len(v)
+
+print '\nCREATED:'
+print '\n'.join(['%s: %s'%(id, db.issue.get(id, 'title'))
+    for id in created])
+
+print '\nRESOLVED:'
+resolved_id = db.status.lookup('resolved')
+print '\n'.join(['%s: %s'%(id, db.issue.get(id, 'title'))
+    for id in summary.get(resolved_id, [])])
+
+print '\nTOP TEN MOST DISCUSSED:'
+messages.sort()
+messages.reverse()
+nmax = messages[0][0]
+fmt = '%%%dd - %%s: %%s'%(int(math.log(nmax, 10)) + 1)
+print '\n'.join([fmt%(num, id, db.issue.get(id, 'title'))
+    for num, id in messages[:10]])
+
+# vim: set filetype=python ts=4 sw=4 et si
index 1a9c5ae41abf0d238921c5b86adb508bdfe6e18c..fd32103a8b6563e929b1ac7db64b9832eb8f3c70 100644 (file)
--- a/setup.py
+++ b/setup.py
 # 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.61 2004-04-17 01:47:15 richard Exp $
+#
+# $Id: setup.py,v 1.105 2008-09-01 01:58:32 richard Exp $
 
 from distutils.core import setup, Extension
 from distutils.util import get_platform
-from distutils.command.build_scripts import build_scripts
+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
@@ -32,6 +35,7 @@ if not hasattr(DistributionMetadata, 'classifiers'):
     DistributionMetadata.classifiers = None
     DistributionMetadata.download_url = None
 
+from roundup import msgfmt
 
 #############################################################################
 ### Build script files
@@ -51,17 +55,84 @@ class build_scripts_create(build_scripts):
             <packagename>.scripts.<mangled_scriptname>
 
         The mangling of script names replaces '-' and '/' characters
-        with '-' and '.', so that they are valid module paths. 
+        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)
@@ -78,35 +149,29 @@ class build_scripts_create(build_scripts):
 
             module = os.path.splitext(os.path.basename(script))[0]
             module = string.translate(module, to_module)
-            cmdopt=self.distribution.command_options
-            if (cmdopt.has_key('install') and
-                cmdopt['install'].has_key('prefix')):
-                prefix = cmdopt['install']['prefix'][1]
-                version = '%d.%d'%sys.version_info[:2]
-                prefix = '''
-import sys
-sys.path.insert(1, "%s/lib/python%s/site-packages")
-'''%(prefix, version)
-            else:
-                prefix = ''
             script_vars = {
-                'python': os.path.normpath(sys.executable),
+                'python': self.python_executable,
                 'package': self.package_name,
                 'module': module,
-                'prefix': prefix,
+                'prefix': self.script_preamble,
             }
 
             self.announce("creating %s" % outfile)
             file = open(outfile, 'w')
 
             try:
-                if sys.platform == "win32":
+                # 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" -O -c "from %(package)s.scripts.%(module)s import run; run()" %%$\n'
-                        'if     "%%_4ver%%" == "" "%(python)s" -O -c "from %(package)s.scripts.%(module)s import run; run()" %%*\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 -O\n%(prefix)s'
+                    file.write('#! %(python)s\n%(prefix)s'
                         'from %(package)s.scripts.%(module)s import run\n'
                         'run()\n'
                         % script_vars)
@@ -125,10 +190,21 @@ def scriptname(path):
     """
     script = os.path.splitext(os.path.basename(path))[0]
     script = string.replace(script, '_', '-')
-    if sys.platform == "win32":
-        script = script + ".bat"
     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.
@@ -136,25 +212,80 @@ def check_manifest():
     try:
         f = open('MANIFEST')
     except:
-        print '\n*** SOURCE ERROR: The MANIFEST file is missing!'
-        sys.exit(1)
+        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)]
+    err.sort()
+    # ignore auto-generated files
+    if err == ['roundup-admin', 'roundup-demo', 'roundup-gettext',
+            'roundup-mailgw', 'roundup-server']:
+        err = []
     if err:
         n = len(manifest)
-        print '\n*** SOURCE ERROR: There are files missing (%d/%d found)!'%(
+        print '\n*** SOURCE WARNING: There are files missing (%d/%d found)!'%(
             n-len(err), n)
-        sys.exit(1)
+        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
 #############################################################################
@@ -171,24 +302,28 @@ def main():
         'roundup.cgi.TAL',
         'roundup.cgi.ZTUtils',
         'roundup.backends',
-        'roundup.scripts'
+        'roundup.scripts',
     ]
     installdatafiles = [
-        ('share/roundup/cgi-bin', ['cgi-bin/roundup.cgi']),
-    ] 
+        ('share/roundup/cgi-bin', ['frontends/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-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 html'.split():
+        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('.'):
@@ -200,54 +335,76 @@ def main():
                 (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(
-        name = "roundup", 
-        version = __version__,
-        description = "A simple-to-use and -install issue-tracking system"
+    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.
+        'long_description':
+'''In this release
+===============
+
+1.4.6 is a bugfix release:
+
+- Fix bug introduced in 1.4.5 in RDBMS full-text indexing
+- Make URL matching code less matchy
 
 If you're upgrading from an older version of Roundup you *must* follow
 the "Software Upgrade" guidelines given in the maintenance documentation.
 
-No, really, this is a BETA and if you don't follow the upgrading steps,
-particularly the bit about BACKING UP YOUR DATA, I'm NOT GOING TO BE HELD
-RESPONSIBLE. This release is NOT FOR GENERAL USE.
-
-I would *greatly* appreciate people giving this release a whirl with a
-copy of their existing setup. It's only through real-world testing of
-beta releases that we can ensure that older trackers will be OK.
-
-This release introduces far too many features to list here. Some
-highlights:
-
-- added postgresql backend (originally from sf patch 761740, many changes
-  since)
-- RDBMS backends implement their session and one-time-key stores and
-  full-text indexers; thus they are now performing their own locking
-  internally
-- added new "actor" automatic property (indicates user who cause the last
-  "activity")
-- all RDBMS backends have sensible data typed columns and indexes on
-  several columns
-- we 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
+Roundup requires python 2.3 or later for correct operation.
+
+To give Roundup a try, just download (see below), unpack and run::
+
+    roundup-demo
+
+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@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).
 ''',
-        author = "Richard Jones",
-        author_email = "richard@users.sourceforge.net",
-        url = 'http://roundup.sourceforge.net/',
-        download_url = 'http://sourceforge.net/project/showfiles.php?group_id=31577',
-        packages = packagelist,
-        classifiers = [
-            'Development Status :: 4 - Beta',
+        'author': "Richard Jones",
+        'author_email': "richard@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',
@@ -264,16 +421,22 @@ highlights:
         ],
 
         # Override certain command classes with our own ones
-        cmdclass = {
+        'cmdclass': {
             'build_scripts': build_scripts_roundup,
+            'build_py': build_py_roundup,
             'build': build_roundup,
+            'bdist_rpm': bdist_rpm_roundup,
         },
-        scripts = roundup_scripts,
+        'scripts': roundup_scripts,
+
+        'data_files':  installdatafiles
+    }
+    if sys.version_info[:2] > (2, 2):
+       setup_args['py_modules'] = py_modules
 
-        data_files =  installdatafiles
-    )
+    setup(**setup_args)
 
 if __name__ == '__main__':
     main()
 
-# vim: set filetype=python ts=4 sw=4 et si
+# vim: set filetype=python sts=4 sw=4 et si :
diff --git a/templates/classic/__init__.py b/templates/classic/__init__.py
deleted file mode 100644 (file)
index e566b54..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-#
-# 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.1 2003-04-17 03:26:03 richard Exp $
-
-import config
-from dbinit import open, init
-from interfaces import Client, MailGW
-
-# vim: set filetype=python ts=4 sw=4 et si
diff --git a/templates/classic/config.py b/templates/classic/config.py
deleted file mode 100644 (file)
index 2712b94..0000000
+++ /dev/null
@@ -1,141 +0,0 @@
-#
-# 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: config.py,v 1.8 2004-03-26 23:45:34 richard Exp $
-
-import os
-
-# roundup home is this package's directory
-TRACKER_HOME=os.path.split(__file__)[0]
-
-# The SMTP mail host that roundup will use to send mail
-MAILHOST = 'localhost'
-
-# If your SMTP mail host requires a username and password for access, then
-# specify them here.
-# eg. MAILUSER = ('username', 'password')
-MAILUSER = ()
-
-# If your SMTP mail host provides or requires TLS (Transport Layer
-# Security) then set MAILHOST_TLS = 'yes'
-# Optionallly, you may also set MAILHOST_TLS_KEYFILE to the name of a PEM
-# formatted file that contains your private key, and MAILHOST_TLS_CERTFILE
-# to the name of a PEM formatted certificate chain file.
-MAILHOST_TLS = 'no'
-MAILHOST_TLS_KEYFILE = ''
-MAILHOST_TLS_CERTFILE = ''
-
-# The domain name used for email addresses.
-MAIL_DOMAIN = 'your.tracker.email.domain.example'
-
-# This is the directory that the database is going to be stored in
-DATABASE = os.path.join(TRACKER_HOME, 'db')
-
-# This is the directory that the HTML templates reside in
-TEMPLATES = os.path.join(TRACKER_HOME, 'html')
-
-# A descriptive name for your roundup instance
-TRACKER_NAME = 'Roundup issue tracker'
-
-# The email address that mail to roundup should go to
-TRACKER_EMAIL = 'issue_tracker@%s'%MAIL_DOMAIN
-
-# 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.
-TRACKER_WEB = 'http://tracker.example/cgi-bin/roundup.cgi/bugs/'
-
-# The email address that roundup will complain to if it runs into trouble
-ADMIN_EMAIL = 'roundup-admin@%s'%MAIL_DOMAIN
-
-# 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.
-DISPATCHER_EMAIL = ADMIN_EMAIL
-
-# 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@tracker.example>
-# the EMAIL_FROM_TAG goes inside the "Foo Bar" quotes like so:
-#    "Foo Bar EMAIL_FROM_TAG" <issue_tracker@tracker.example>
-EMAIL_FROM_TAG = ""
-
-# 
-# SECURITY DEFINITIONS
-#
-# define the Roles that a user gets when they register with the tracker
-# these are a comma-separated string of role names (e.g. 'Admin,User')
-NEW_WEB_USER_ROLES = 'User'
-NEW_EMAIL_USER_ROLES = 'User'
-
-# Send error message emails to the dispatcher, user, or both?
-# If 'dispatcher', error messages will only be sent to the dispatcher.
-# If 'user',       error messages will only be sent to the user.
-# If 'both',       error messages will be sent to both individuals.
-# The dispatcher is configured using the DISPATCHER_EMAIL setting, which
-# defaults to ADMIN_EMAIL.
-ERROR_MESSAGES_TO = 'user'
-
-# Send nosy messages to the author of the message
-MESSAGES_TO_AUTHOR = 'no'           # either 'yes' or 'no'
-
-# 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_AUTHOR_TO_NOSY = 'new'          # one of 'yes', 'no', '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.
-ADD_RECIPIENTS_TO_NOSY = 'new'      # either 'yes', 'no', 'new'
-
-# Where to place the email signature
-EMAIL_SIGNATURE_POSITION = 'bottom' # one of 'top', 'bottom', 'none'
-
-# Keep email citations when accepting messages. Setting this to "no" strips
-# out "quoted" text from the message. Signatures are also stripped.
-EMAIL_KEEP_QUOTED_TEXT = 'yes'      # either 'yes' or 'no'
-
-# Preserve the email body as is - that is, keep the citations _and_
-# signatures.
-EMAIL_LEAVE_BODY_UNCHANGED = 'no'   # either 'yes' or 'no'
-
-# Default class to use in the mailgw if one isn't supplied in email
-# subjects. To disable, comment out the variable below or leave it blank.
-# Examples:
-MAIL_DEFAULT_CLASS = 'issue'   # use "issue" class by default
-#MAIL_DEFAULT_CLASS = ''        # disable (or just comment the var out)
-
-# 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.
-HTML_VERSION = 'html4'         # either 'html4' or 'xhtml'
-
-# 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'.
-EMAIL_CHARSET = 'utf-8'
-#EMAIL_CHARSET = 'iso-8859-1'   # use this instead for Eudora users
-
-# 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
-
-# vim: set filetype=python ts=4 sw=4 et si
diff --git a/templates/classic/dbinit.py b/templates/classic/dbinit.py
deleted file mode 100644 (file)
index f38dcab..0000000
+++ /dev/null
@@ -1,200 +0,0 @@
-#
-# 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: dbinit.py,v 1.7 2004-03-26 04:50:50 richard Exp $
-
-import os
-
-import config
-from select_db import Database, Class, FileClass, IssueClass
-
-def open(name=None):
-    ''' as from the roundupdb method openDB 
-    ''' 
-    from roundup.hyperdb import String, Password, Date, Link, Multilink
-    from roundup.hyperdb import Interval, Boolean, Number
-
-    # open the database
-    db = Database(config, name)
-
-    #
-    # Now initialise the schema. Must do this each time the database is
-    # opened.
-    #
-
-    # Class automatically gets these properties:
-    #   creation = Date()
-    #   activity = Date()
-    #   creator = Link('user')
-    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")
-    
-    query = Class(db, "query",
-                    klass=String(),     name=String(),
-                    url=String(),       private_for=Link('user'))
-
-    # add any additional database schema configuration here
-    
-    # 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(),
-                    timezone=String())
-    user.setkey("username")
-
-    # FileClass automatically gets these properties:
-    #   content = String()    [saved to disk in <tracker home>/db/files/]
-    #   (it also gets the Class properties creation, activity and creator)
-    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(),       type=String())
-
-    # 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"))
-
-    #
-    # SECURITY SETTINGS
-    #
-    # See the configuration and customisation document for information
-    # about security setup.
-    # 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)
-    for cl in 'priority', 'status':
-        p = db.security.getPermission('View', 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 view issues (which implies being
-    #   able to view all linked information too
-    for cl in 'issue', 'file', 'msg', 'keyword', 'priority', 'status':
-        p = db.security.getPermission('View', cl)
-        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)
-
-    import detectors
-    detectors.init(db)
-
-    # schema is set up - run any post-initialisation
-    db.post_init()
-    return db
-def init(adminpw): 
-    ''' as from the roundupdb method initDB 
-    Open the new database, and add new nodes - used for initialisation. You
-    can edit this before running the "roundup-admin initialise" command to
-    change the initial database entries.
-    ''' 
-    dbdir = os.path.join(config.DATABASE, 'files')
-    if not os.path.isdir(dbdir):
-        os.makedirs(dbdir)
-
-    db = open("admin")
-    db.clear()
-
-    #
-    # 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=config.ADMIN_EMAIL, roles='Admin')
-    user.create(username="anonymous", roles='Anonymous')
-
-    # add any additional database create steps here - but only if you
-    # haven't initialised the database with the admin "initialise" command
-
-    db.commit()
-    db.close()
-
-# vim: set filetype=python ts=4 sw=4 et si
-
-#SHA: 92c54c05ba9f59453dc74fa9fdbbae34f7a9c077
diff --git a/templates/classic/detectors/__init__.py b/templates/classic/detectors/__init__.py
deleted file mode 100644 (file)
index 7f3ec6d..0000000
+++ /dev/null
@@ -1,40 +0,0 @@
-#
-# 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 2003-10-07 06:18:45 richard Exp $
-
-import sys, os, imp
-
-def init(db):
-    ''' execute the init functions of all the modules in this directory
-    '''
-    this_dir = os.path.split(__file__)[0]
-    for filename in os.listdir(this_dir):
-        name, ext = os.path.splitext(filename)
-        if name == '__init__':
-            continue
-        if ext == '.py':
-            path = os.path.abspath(os.path.join(this_dir, filename))
-            fp = open(path)
-            try:
-                module = imp.load_module(name, fp, path,
-                    ('.py', 'r', imp.PY_SOURCE))
-            finally:
-                fp.close()
-            module.init(db)
-
-# vim: set filetype=python ts=4 sw=4 et si
index 2558213ac57c3fd145810ca9dc1f91b316eabc66..def2e82873cd01f7b2bc99be29e017500e8c70b0 100644 (file)
@@ -1,4 +1,4 @@
-#$Id: messagesummary.py,v 1.1 2003-04-17 03:26:38 richard Exp $
+#$Id: messagesummary.py,v 1.2 2007-04-03 06:47:21 a1s Exp $
 
 from roundup.mailgw import parseContent
 
@@ -8,7 +8,7 @@ def summarygenerator(db, cl, nodeid, newvalues):
     if newvalues.has_key('summary') or not newvalues.has_key('content'):
         return
 
-    summary, content = parseContent(newvalues['content'], 1, 1)
+    summary, content = parseContent(newvalues['content'], config=db.config)
     newvalues['summary'] = summary
 
 
index 750621112e90d4bed5261cbfbf0158c930b04658..c732fd013307e779eb71cdc7b1c6cbe10bdfb5f4 100644 (file)
@@ -15,7 +15,9 @@
 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 # 
-#$Id: nosyreaction.py,v 1.2 2003-09-04 00:47:01 richard Exp $
+#$Id: nosyreaction.py,v 1.4 2005-04-04 08:47:14 richard Exp $
+
+import sets
 
 from roundup import roundupdb, hyperdb
 
@@ -65,7 +67,7 @@ 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 = {}
+    current_nosy = sets.Set()
     if nodeid is None:
         ok = ('new', 'yes')
     else:
@@ -75,8 +77,7 @@ def updatenosy(db, cl, nodeid, newvalues):
         if not newvalues.has_key('nosy'):
             nosy = cl.get(nodeid, 'nosy')
             for value in nosy:
-                if not current.has_key(value):
-                    current[value] = 1
+                current_nosy.add(value)
 
     # if the nosy list changed in this transaction, init from the new value
     if newvalues.has_key('nosy'):
@@ -84,8 +85,9 @@ def updatenosy(db, cl, nodeid, newvalues):
         for value in nosy:
             if not db.hasnode('user', value):
                 continue
-            if not current.has_key(value):
-                current[value] = 1
+            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:
@@ -95,8 +97,7 @@ def updatenosy(db, cl, nodeid, newvalues):
         elif isinstance(propdef['assignedto'], hyperdb.Multilink):
             assignedto_ids = newvalues['assignedto']
         for assignedto_id in assignedto_ids:
-            if not current.has_key(assignedto_id):
-                current[assignedto_id] = 1
+            new_nosy.add(assignedto_id)
 
     # see if there's any new messages - if so, possibly add the author and
     # recipient to the nosy
@@ -107,7 +108,6 @@ def updatenosy(db, cl, nodeid, newvalues):
         else:
             ok = ('yes',)
             # figure which of the messages now on the issue weren't
-            # there before
             oldmessages = cl.get(nodeid, 'messages')
             messages = []
             for msgid in newvalues['messages']:
@@ -123,15 +123,16 @@ def updatenosy(db, cl, nodeid, newvalues):
         for msgid in messages:
             if add_author in ok:
                 authid = msg.get(msgid, 'author')
-                current[authid] = 1
+                new_nosy.add(authid)
 
             # add on the recipients of the message
             if add_recips in ok:
                 for recipient in msg.get(msgid, 'recipients'):
-                    current[recipient] = 1
+                    new_nosy.add(recipient)
 
-    # that's it, save off the new nosy list
-    newvalues['nosy'] = current.keys()
+    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)
index b12c7e6ca0791024e5df3c7da6a2fdc6d301149c..fc71a1c567b0756a4aec93a2fce79285265a89aa 100644 (file)
 # 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 $
+#$Id: userauditor.py,v 1.9 2007-09-12 21:11:13 jpend Exp $
+
+import re
+
+# regular expression thanks to: http://www.regular-expressions.info/email.html
+# this is the "99.99% solution for syntax only".
+email_regexp = (r"[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*", r"(localhost|(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9]))")
+email_rfc = re.compile('^' + email_regexp[0] + '@' + email_regexp[1] + '$', re.IGNORECASE)
+email_local = re.compile('^' + email_regexp[0] + '$', re.IGNORECASE)
+
+def valid_address(address):
+    ''' If we see an @-symbol in the address then check against the full
+        RFC syntax. Otherwise it is a local-only address so only check
+        the local part of the RFC syntax.
+    '''
+    if '@' in address:
+        return email_rfc.match(address)
+    else:
+        return email_local.match(address)
+
+def get_addresses(user):
+    ''' iterate over all known addresses in a newvalues dict
+        this takes of the address/alterate_addresses handling
+    '''
+    if user.has_key('address'):
+        yield user['address']
+    if user.get('alternate_addresses', None):
+        for address in user['alternate_addresses'].split('\n'):
+            yield address
 
 def audit_user_fields(db, cl, nodeid, newvalues):
     ''' Make sure user properties are valid.
 
-        - email address has no spaces in it
+        - email address is syntactically valid
+        - email address is unique
         - roles specified exist
+        - timezone is valid
     '''
-    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):
+    for address in get_addresses(newvalues):
+        if not valid_address(address):
+            raise ValueError, 'Email address syntax is invalid'
+
+        check_main = db.user.stringFind(address=address)
+        # make sure none of the alts are owned by anyone other than us (x!=nodeid)
+        check_alts = [x for x in db.user.filter(None, {'alternate_addresses' : address}) if x != nodeid]
+        if check_main or check_alts:
+            raise ValueError, 'Email address %s already in use' % address
+
+    for rolename in [r.lower().strip() for r in newvalues.get('roles', '').split(',')]:
+            if rolename and not db.security.role.has_key(rolename):
                 raise ValueError, 'Role "%s" does not exist'%rolename
 
+    tz = newvalues.get('timezone', None)
+    if tz:
+        # if they set a new timezone validate the timezone by attempting to
+        # use it before we store it to the db.
+        import roundup.date
+        import datetime
+        try:
+            TZ = roundup.date.get_timezone(tz)
+            dt = datetime.datetime.now()
+            local = TZ.localize(dt).utctimetuple()
+        except IOError:
+            raise ValueError, 'Timezone "%s" does not exist' % tz
+        except ValueError:
+            raise ValueError, 'Timezone "%s" exceeds valid range [-23...23]' % tz
 
 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
+# vim: sts=4 sw=4 et si
diff --git a/templates/classic/extensions/README.txt b/templates/classic/extensions/README.txt
new file mode 100644 (file)
index 0000000..ff4dff3
--- /dev/null
@@ -0,0 +1,6 @@
+This directory is for tracker extensions:
+
+- CGI Actions
+- Templating functions
+
+See the customisation doc for more information.
diff --git a/templates/classic/html/_generic.404.html b/templates/classic/html/_generic.404.html
new file mode 100644 (file)
index 0000000..71c9e0e
--- /dev/null
@@ -0,0 +1,9 @@
+<html>
+<head>
+<title>Item Not Found</title>
+</head>
+
+<body>
+There is no <span tal:content="context/_classname" /> with id <span tal:content="context/id"/>
+</body>
+</html>
diff --git a/templates/classic/html/_generic.calendar.html b/templates/classic/html/_generic.calendar.html
new file mode 100644 (file)
index 0000000..0f9cf40
--- /dev/null
@@ -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>
index 34dbece1b2692e0d2e4b7a6ae7d6b48caf3289b0..2a2768a3d7b576fe73902b0150d4654ce61d9adf 100644 (file)
@@ -1,12 +1,16 @@
 <tal:block metal:use-macro="templates/page/macros/icing">
-  <title metal:fill-slot="head_title"
-         tal:content="python:context._classname.capitalize()+' Edit Collision'"></title>
-  <span metal:fill-slot="body_title" tal:omit-tag="python:1"
-        tal:content="python:context._classname.capitalize()+' Edit Collision'"></span>
-  <td class="content" metal:fill-slot="content">
-    There has been a collision. Another user updated this node while you were
-    editing. Please <a tal:attributes="href context/designator">reload</a>
-    the node and review your edits.
-  </td>
-</tal:block>
+<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>
diff --git a/templates/classic/html/_generic.help-empty.html b/templates/classic/html/_generic.help-empty.html
new file mode 100644 (file)
index 0000000..65469fc
--- /dev/null
@@ -0,0 +1,8 @@
+<html>
+  <head>
+    <title>Empty page (no search performed yet)</title>
+  </head>
+  <body>
+    <p i18n:translate="">Please specify your search parameters!</p>
+  </body>
+</html>
diff --git a/templates/classic/html/_generic.help-list.html b/templates/classic/html/_generic.help-list.html
new file mode 100644 (file)
index 0000000..66cbb90
--- /dev/null
@@ -0,0 +1,83 @@
+<!-- $Id: _generic.help-list.html,v 1.2 2008-03-01 08:18:07 richard Exp $ vim: sw=2 ts=8 et
+--><html tal:define="vok context/is_view_ok">
+  <head>
+    <title>Search result for user helper</title>
+    <link rel="stylesheet" type="text/css" href="@@file/style.css" />
+    <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  = parent.opener.document.${request/form/form/value};
+        field  = '${request/form/property/value}';
+    //-->"></script>
+    <script src="@@file/help_controls.js" type="text/javascript"></script>
+<script type="text/javascript"><!--
+var text_field = parent.submit.document.frm_help.text_preview;
+//--></script>
+  </head>
+  <body>
+    <pre tal:content="request/env/QUERY_STRING" tal:condition=false />
+
+  <p tal:condition="not:vok" i18n:translate="">You are not
+  allowed to view this page.</p>
+
+  <tal:if condition="context/is_view_ok">
+  <tal:def define="batch request/batch;">
+  <form name=dummyform>
+    <table width="100%"
+      tal:define="template string:help-list"
+      metal:use-macro="templates/help/macros/batch_navi"
+      >
+      <tr class="navigation">
+       <th>
+        <a href="#">&lt;&lt; previous</a>
+       </th>
+       <th i18n:translate="">1..25 out of 50
+       </th>
+       <th>
+        <a href="#">next &gt;&gt;</a>
+       </th>
+      </tr>
+     </table>
+
+  <form name=dummyform>
+  <table class="classhelp"
+    tal:define="
+       props python:request.form['properties'].value.split(',');
+       legend templates/help/macros/legend;
+    "><thead>
+      <tr metal:use-macro="legend">
+         <th>&nbsp;<b>x</b></th>
+         <th tal:repeat="prop props" tal:content="prop" i18n:translate=""></th>
+       </tr>
+     </thead>
+     <tfoot tal:condition=true>
+       <tr metal:use-macro="legend" />
+     </tfoot>
+     <tbody>
+       <tr tal:repeat="item batch">
+         <tal:block tal:define="attr python:item[props[0]]" >
+           <td>
+             <input name="check"
+             onclick="switch_val(text_field, this);" type="checkbox"
+             tal:attributes="value attr; id string:id_$attr" />
+             </td>
+             <td tal:repeat="prop props">
+                 <label class="classhelp-label"
+                        tal:attributes="for string:id_$attr"
+                        tal:content="python:item[prop]"></label>
+             </td>
+           </tal:block>
+         </tr>
+       </tbody>
+     </table>
+   </form>
+     </tal:def>
+     </tal:if>
+     
+     <pre tal:content=request tal:condition=false />
+     <script type="text/javascript"><!--
+       parent.submit.document.frm_help.cb_listpresent.checked=true;
+       reviseList_framed(document.dummyform, text_field)
+     //--></script>
+  </body>
+</html>
diff --git a/templates/classic/html/_generic.help-search.html b/templates/classic/html/_generic.help-search.html
new file mode 100644 (file)
index 0000000..01d657e
--- /dev/null
@@ -0,0 +1,13 @@
+<html>
+  <head>
+    <title>Frame for search input fields</title>
+  </head>
+  <body>
+    <p i18n:translate="">Generic template
+    <span tal:replace="request/template" i18n:name="template">help-search</span>
+    or version for class
+    <span tal:replace="request/classname" i18n:name="classname">user</span>
+    is not yet implemented</p>
+  </body>
+</html>
+
diff --git a/templates/classic/html/_generic.help-submit.html b/templates/classic/html/_generic.help-submit.html
new file mode 100644 (file)
index 0000000..b13aa4a
--- /dev/null
@@ -0,0 +1,73 @@
+<html>
+  <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>Generic submit page for framed helper windows</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  = parent.opener.document.${request/form/form/value};
+callingform=form
+field  = '${request/form/property/value}';
+var listform = null
+function listPresent() {
+  return document.frm_help.cb_listpresent.checked
+}
+function getListForm() {
+  if (listPresent()) {
+    return parent.list.document.forms.dummyform
+  } else {
+    return null
+  }
+}
+
+
+function checkListForm() {
+  // global listform
+  if (listform != null)
+    if (parent.list.document.dummyform) {
+      listform = parent.list.document.dummyform
+      alert(listform)
+    }
+
+  var bol= listform != null
+  alert('checkListForm: bol='+bol)
+  return bol
+}
+//-->">
+      </script>
+      <script src="@@file/help_controls.js" type="text/javascript"></script>
+      </tal:block>
+  </head>
+ <body class="body" onload="parent.focus();" id="submit">
+ <pre tal:content="request/env/QUERY_STRING" tal:condition=false />
+ <form name="frm_help"
+       tal:define="batch request/batch;
+       props python:request.form['properties'].value.split(',')"
+       class="help-submit"
+       id="classhelp-controls">
+     <div style="width:100%;text-align:left;margin-bottom:0.2em">
+       <input type="text" name="text_preview" size="24" class="preview"
+       onchange="f=getListForm();if(f){ reviseList_framed(f, this)};"
+       />
+     </div>
+     <input type=checkbox name="cb_listpresent" readonly="readonly" style="display:none">
+     <input type="button" id="btn_cancel"
+            value=" Cancel " onclick="parent.close();return false;"
+            i18n:attributes="value" />
+     <input type="reset" id="btn_reset"
+     onclick="text_field.value=original_field;f=getListForm();if (f) {reviseList_framed(f, this)};return false"
+            />
+     <input type="submit" id="btn_apply" class="apply"
+            value=" Apply " onclick="callingform[field].value=text_field.value; parent.close();"
+            i18n:attributes="value" />
+ </form>
+ <script type="text/javascript"><!--
+var text_field = document.frm_help.text_preview;
+original_field=form[field].value;
+text_field.value=original_field;
+//--></script>
+ </body>
+</html>
index 88088ce9217ba7fd7b02d9612cd7f4a638147d8b..069c0a6cb8eeca5d5798550001cec3af0a170ed2 100644 (file)
@@ -1,76 +1,97 @@
 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
-<html>
+<html tal:define="property request/form/property/value" >
   <head>
       <link rel="stylesheet" type="text/css" href="@@file/style.css" />
-      <meta http-equiv="Content-Type" content="text/html; charset=utf-8;" />
+      <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 tal:content="string:${request/form/property/value} help">Property</title>
-      <script language="Javascript" type="text/javascript" 
+      <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
-          field = '${request/form/property/value}';" >
+          form  = window.opener.document.${request/form/form/value};
+          field  = '${request/form/property/value}';">
       </script>
-      <script src="@@file/help_controls.js" type="text/javascript"><!-- 
+      <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="start python:int(request.form['@startwith'].value);
-                   batch python:utils.Batch(context.list(), 500, start);
+       tal:define="batch request/batch;
                    props python:request.form['properties'].value.split(',')">
-     
+
      <div id="classhelp-controls">
-       <!--input type="button" name="btn_clear" 
+       <!--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();"/>
+       <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();"/>     
+              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"></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 type="checkbox" name="check" 
+         <tal:block tal:define="attr python:item[props[0]]" >
+           <td>
+             <input name="check"
                  onclick="updatePreview();"
-                 tal:attributes="value attr; id string:id_$attr" />
+                 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>
+                        tal:content="python:item[prop]"></label>
              </td>
            </tal:block>
        </tr>
        <tr>
            <th>&nbsp;<b>x</b></th>
-           <th tal:repeat="prop props" tal:content="prop"></th>
+           <th tal:repeat="prop props" tal:content="prop" i18n:translate=""></th>
        </tr>
      </table>
-     <table width="100%">
-      <tr class="navigation">
-       <th>
-        <a tal:define="prev batch/previous" tal:condition="prev"
-            tal:attributes="href string:${request/classname}?@template=help&@startwith=${prev/first}&property=${request/form/property/value}&properties=${request/form/properties/value}">&lt;&lt; previous</a>
-        &nbsp;
-       </th>
-       <th tal:content="python: '%d...%d out of %d'%(batch.start,
-               batch.start+batch.length-1, batch.sequence_length)">current</th>
-       <th>
-        <a tal:define="next batch/next" tal:condition="next"
-            tal:attributes="href string:${request/classname}?@template=help&@startwith=${next/first}&property=${request/form/property/value}&properties=${request/form/properties/value}">next &gt;&gt;</a>
-        &nbsp;
-       </th>
-      </tr>
-     </table>
 
  </form>
  </body>
index fa5a10e3dffaf5f225f62cc87955948497666a5a..a41ab0ac190624a4025b66aacf803370b3b380cd 100644 (file)
@@ -1,19 +1,31 @@
 <!-- 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"
-       tal:content="python:context._classname.capitalize()+' editing'"></title>
-<span metal:fill-slot="body_title" tal:omit-tag="python:1"
-      tal:content="python:context._classname.capitalize()+' editing'"></span>
+<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())">
-You are not allowed to view this page.
-</span>
+<span tal:condition="python:not (context.is_view_ok() or context.is_edit_ok()
+ or request.user.hasRole('Anonymous'))"
+ tal:omit-tag="python:1" i18n:translate=""
+>You are not allowed to view this page.</span>
+
+<span tal:condition="python:not (context.is_view_ok() or context.is_edit_ok())
+ and request.user.hasRole('Anonymous')"
+ tal:omit-tag="python:1" i18n:translate=""
+>Please login with your username and password.</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" />
+ 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
@@ -21,7 +33,7 @@ You are not allowed to view this page.
 </p>
 
 <p class="form-help">
- Multilink properties have their multiple values colon (":") separated 
+ Multilink properties have their multiple values colon (":") separated
  (... ,"one:two:three", ...)
 </p>
 
@@ -29,19 +41,29 @@ You are not allowed to view this page.
  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" cols="60" name="rows" tal:content="context/csv"></textarea>
+<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">
+<input type="submit" value="Edit Items" i18n:attributes="value">
 </form>
 </tal:block>
 
-<tal:block tal:condition="context/is_only_view_ok">
-view ok
-</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>
 
index ae7d828ceebb58d86692f722a96aa2c7c31a804f..d9b98aefc0a8418f4d695dba36ad989c37286f0b 100644 (file)
@@ -1,16 +1,26 @@
 <tal:block metal:use-macro="templates/page/macros/icing">
-<title metal:fill-slot="head_title"
-       tal:content="python:context._classname.capitalize()+' editing'"></title>
-<span metal:fill-slot="body_title" tal:omit-tag="python:1"
-      tal:content="python:context._classname.capitalize()+' editing'"></span>
+<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())">
-You are not allowed to view this page.
-</span>
+<p tal:condition="python:not (context.is_view_ok()
+ or request.user.hasRole('Anonymous'))" i18n:translate="">
+ You are not allowed to view this page.</p>
+
+<p tal:condition="python:not context.is_view_ok()
+ and request.user.hasRole('Anonymous')" i18n:translate="">
+ Please login with your username and password.</p>
+
+<div tal:condition="context/is_view_ok">
 
 <form method="POST" onSubmit="return submit_once()"
-      enctype="multipart/form-data" tal:condition="context/is_edit_ok"
+      enctype="multipart/form-data" tal:condition="context/is_view_ok"
       tal:attributes="action context/designator">
 
 <input type="hidden" name="@template" value="item">
@@ -18,8 +28,8 @@ You are not allowed to view this page.
 <table class="form">
 
 <tr tal:repeat="prop python:db[context._classname].properties()">
- <tal:block tal:condition="python:prop._name not in ('id', 'creator',
-                                  'creation', 'activity')">
+ <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>
@@ -34,21 +44,9 @@ You are not allowed to view this page.
 
 </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="context/id" tal:replace="structure context/history" />
 
-<tal:block tal:condition="python:context.id and context.is_view_ok()">
- <tal:block tal:replace="structure context/history" />
-</tal:block>
+</div>
 
 </td>
 
index b34a11ce2460ba8678116c4860f56465b8863bdf..9717920e67b8463211708fddba0c664013f8bafa 100644 (file)
@@ -1,26 +1,29 @@
 <!-- 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"
List of files - <span tal:replace="config/TRACKER_NAME" />
-</title> 
-<span metal:fill-slot="body_title" tal:omit-tag="python:1">List of files</span>
+<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">
-<tr><th style="padding-right: 10">Download</th>
-    <th style="padding-right: 10">Content Type</th>
-    <th style="padding-right: 10">Uploaded By</th>
-    <th style="padding-right: 10">Date</th>
-</tr>
-<tr tal:repeat="file context/list">
- <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>
+<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>
index e87b1dfeee49b110d56d828976b62fd803ecab19..d223fd7d12d5de322e5a42740ab997eab89f2826 100644 (file)
@@ -1,24 +1,30 @@
 <tal:block metal:use-macro="templates/page/macros/icing">
-<title metal:fill-slot="head_title">File display</title>
-<span metal:fill-slot="body_title" tal:omit-tag="python:1">File display</span>
+<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">
 
-<span tal:condition="python:not (context.is_view_ok() or context.is_edit_ok())">
-You are not allowed to view this page.
-</span>
+<p tal:condition="python:not (context.is_view_ok()
+ or request.user.hasRole('Anonymous'))" i18n:translate="">
+ You are not allowed to view this page.</p>
+
+<p tal:condition="python:not context.is_view_ok()
+ and request.user.hasRole('Anonymous')" i18n:translate="">
+ Please login with your username and password.</p>
 
 <form method="POST" onSubmit="return submit_once()"
-      enctype="multipart/form-data" tal:condition="context/is_edit_ok"
+      enctype="multipart/form-data" tal:condition="context/is_view_ok"
       tal:attributes="action context/designator">
 
 <table class="form">
  <tr>
-  <th>Name</th>
+  <th i18n:translate="">Name</th>
   <td tal:content="structure context/name/field"></td>
  </tr>
  <tr>
-  <th>Content Type</th>
+  <th i18n:translate="">Content Type</th>
   <td tal:content="structure context/type/field"></td>
  </tr>
 
@@ -37,21 +43,11 @@ You are not allowed to view this page.
 </form>
 
 <a tal:condition="python:context.id and context.is_view_ok()"
-   tal:attributes="href string:file${context/id}/${context/name}">download</a>
+ tal:attributes="href string:file${context/id}/${context/name}"
+ i18n:translate="">download</a>
 
-<table class="form" tal:condition="context/is_only_view_ok">
- <tr>
-  <th>Name</th>
-  <td tal:content="context/name"></td>
- </tr>
- <tr>
-  <th>Content Type</th>
-  <td tal:content="context/type"></td>
- </tr>
-</table>
+<tal:block tal:condition="context/id" tal:replace="structure context/history" />
 
-<tal:block tal:condition="python:context.id and context.is_view_ok()"
-           tal:replace="structure context/history" />
 </td>
 
 </tal:block>
diff --git a/templates/classic/html/help.html b/templates/classic/html/help.html
new file mode 100644 (file)
index 0000000..1571a6f
--- /dev/null
@@ -0,0 +1,38 @@
+<!--
+Macros for framed help windows
+-->
+
+<!-- legend for helper search results -->
+<thead>
+<tr metal:define-macro="legend">
+  <th><b>x</b></th>
+  <th tal:repeat="prop props" tal:content="prop" i18n:translate=""></th>
+</tr>
+</thead>
+
+<table width="100%"
+  metal:define-macro="batch_navi"
+  tal:define="prev batch/previous;
+  next batch/next;
+  "
+  tal:condition="python:prev or next">
+  <tr class="navigation">
+   <th width="30%">
+    <a tal:condition="prev"
+       tal:attributes="href python:request.indexargs_url(request.classname, {'@template':'help-list', 'property': request.form['property'].value, 'properties': request.form['properties'].value, 'form': request.form['form'].value, '@startwith':prev.first, '@pagesize':prev.size})"
+       i18n:translate="" >&lt;&lt; previous</a>
+    &nbsp;
+   </th>
+   <th i18n:translate="" width="40%"><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 width="30%">
+    <a tal:condition="next"
+       tal:attributes="href python:request.indexargs_url(request.classname, {'@template':'help-list', 'property': request.form['property'].value, 'properties': request.form['properties'].value, 'form': request.form['form'].value, '@startwith':next.first, '@pagesize':next.size})"
+       i18n:translate="" >next &gt;&gt;</a>
+    &nbsp;
+   </th>
+  </tr>
+ </table>
index 050c8dc6e79456246f76308184d58f354b1e8221..480170ac63312d4fb4d889a2cdc616a3f7402f26 100644 (file)
@@ -1,47 +1,9 @@
-// initial values for either Nosy, Superseder, Topic and Waiting On,
+// initial values for either Nosy, Superseder, Keyword and Waiting On,
 // depending on which has called
+original_field = form[field].value;
 
-original_field = window.opener.document.itemSynopsis[field].value;
-
-
-// pop() and push() methods for pre5.5 IE browsers
-
-function bName() {
-    // test for IE 
-    if (navigator.appName == "Microsoft Internet Explorer")
-      return 1;
-    return 0;
-}
-
-function bVer() {
-    // return version number (e.g., 4.03)
-    msieIndex = navigator.appVersion.indexOf("MSIE") + 5;
-    return(parseFloat(navigator.appVersion.substr(msieIndex,3)));
-}
-
-function pop() {
-    // make a pop method for old IE browsers
-    var lastElement = this[this.length - 1];
-    this.length--;
-    return lastElement;
-}
-
-function push() {
-    // make a pop method for old IE browsers
-    var sub = this.length;
-    for (var i = 0; i < push.arguments.length; ++i) {
-      this[sub] = push.arguments[i];
-        sub++;
-  }
-}
-
-// add the pop() and push() method to Array if they're not there
-if (!Array.prototype.pop) {
-    Array.prototype.pop = pop;
-}
-if (!Array.prototype.push) {
-    Array.prototype.push = push;
-}
+// Some browsers (ok, IE) don't define the "undefined" variable.
+undefined = document.geez_IE_is_really_friggin_annoying;
 
 function trim(value) {
   var temp = value;
@@ -53,27 +15,49 @@ function trim(value) {
 }
 
 function determineList() {
-  // generate a comma-separated list of the checked items
+     // 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;
+}
+
+/**
+ * update the field in the opening window;
+ * the text_field variable must be set in the calling page
+ */
+function updateOpener() {
+  // write back to opener window
   if (document.frm_help.check==undefined) { return; }
-  var list = new Array();
-  if (document.frm_help.check.length==undefined) {
-      if (document.frm_help.check.checked) {
-          list.push(document.frm_help.check.value);
-      }
-  } else {
-      for (box=0; box < document.frm_help.check.length; box++) {
-          if (document.frm_help.check[box].checked) {
-              list.push(document.frm_help.check[box].value);
-          }
-      }
-  }
-  return new String(list.join(','));
+  form[field].value = text_field.value;
 }
 
 function updateList() {
   // write back to opener window
   if (document.frm_help.check==undefined) { return; }
-  window.opener.document.itemSynopsis[field].value = determineList();
+  form[field].value = determineList();
 }
 
 function updatePreview() {
@@ -90,6 +74,40 @@ function clearList() {
   }
 }
 
+function reviseList_framed(form, textfield) {
+  // update the checkboxes based on the preview field
+  // alert('reviseList_framed')
+  // alert(form)
+  if (form.check==undefined)
+      return;
+  // alert(textfield)
+  var to_check;
+  var list = textfield.value.split(",");
+  if (form.check.length==undefined) {
+      check = form.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 < form.check.length; box++) {
+      check = form.check[box];
+      to_check = false;
+      for (val in list) {
+          if (check.value==trim(list[val])) {
+              to_check = true;
+              break;
+          }
+      }
+      check.checked = to_check;
+    }
+  }
+}
+
 function reviseList(vals) {
   // update the checkboxes based on the preview field
   if (document.frm_help.check==undefined) { return; }
@@ -142,8 +160,165 @@ function focusField(name) {
 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.focus){obj.focus();}
       if (obj && obj.select){obj.select();}
     }
 }
 
+function checkRequiredFields(fields)
+{
+    var bonk='';
+    var res='';
+    var argv = checkRequiredFields.arguments;
+    var argc = argv.length;
+    var input = '';
+    var val='';
+
+    for (var i=0; i < argc; i++) {
+        fi = argv[i];
+        input = document.getElementById(fi);
+        if (input) {
+            val = input.value
+            if (val == '' || val == '-1' || val == -1) {
+                if (res == '') {
+                    res = fi;
+                    bonk = input;
+                } else {
+                    res += ', '+fi;
+                }
+            }
+        } else {
+            alert('Field with id='+fi+' not found!')
+        }
+    }
+    if (res == '') {
+        return submit_once();
+    } else {
+        alert('Missing value here ('+res+')!');
+        if (window.event && window.event.returnvalue) {
+            event.returnValue = 0;    // work-around for IE
+        }
+        bonk.focus();
+        return false;
+    }
+}
+
+/**
+ * seeks the given value (2nd argument)
+ * in the value of the given input element (1st argument),
+ * which is considered a list of values, separated by commas
+ */
+function has_value(input, val)
+{
+    var actval = input.value
+    var arr = feld.value.split(',');
+    var max = arr.length;
+    for (i=0;i<max;i++) {
+        if (trim(arr[i]) == val) {
+            return true
+        }
+    }
+    return false
+}
+
+/**
+ * Switch Value:
+ * change the value of the given input field (might be of type text or hidden),
+ * adding or removing the value of the given checkbox field (might be a radio
+ * button as well)
+ *
+ * This function doesn't care whether or not the checkboxes of all values of
+ * interest are present; but of course it doesn't have total control of the
+ * text field.
+ */
+function switch_val(text, check)
+{
+    var switched_val = check.value
+    var arr = text.value.split(',')
+    var max = arr.length
+    if (check.checked) {
+        for (i=0; i<max; i++) {
+            if (trim(arr[i]) == switched_val) {
+                return
+            }
+        }
+       if (text.value)
+            text.value = text.value+','+switched_val
+       else
+            text.value = switched_val
+    } else {
+        var neu = ''
+       var changed = false
+        for (i=0; i<max; i++) {
+            if (trim(arr[i]) == switched_val) {
+                changed=true
+            } else {
+                neu = neu+','+trim(arr[i])
+            }
+        }
+        if (changed) {
+            text.value = neu.substr(1)
+        }
+    }
+}
+
+/**
+ * append the given value (2nd argument) to an input field
+ * (1st argument) which contains comma-separated values;
+ * see --> remove_val()
+ *
+ * This will work nicely even for batched lists
+ */
+function append_val(name, val)
+{
+    var feld = document.itemSynopsis[name];
+    var actval = feld.value;
+    if (actval == '') {
+        feld.value = val
+    } else {
+        var arr = feld.value.split(',');
+        var max = arr.length;
+        for (i=0;i<max;i++) {
+            if (trim(arr[i]) == val) {
+                return
+            }
+        }
+        feld.value = actval+','+val
+    }
+}
+
+/**
+ * remove the given value (2nd argument) from the comma-separated values
+ * of the given input element (1st argument); see --> append_val()
+ */
+function remove_val(name, val)
+{
+    var feld = document.itemSynopsis[name];
+    var actval = feld.value;
+    var changed=false;
+    if (actval == '') {
+       return
+    } else {
+        var arr = feld.value.split(',');
+        var max = arr.length;
+        var neu = ''
+        for (i=0;i<max;i++) {
+            if (trim(arr[i]) == val) {
+                changed=true
+            } else {
+                neu = neu+','+trim(arr[i])
+            }
+        }
+        if (changed) {
+            feld.value = neu.substr(1)
+        }
+    }
+}
+
+/**
+ * give the focus to the element given by id
+ */
+function focus2id(name)
+{
+    document.getElementById(name).focus();
+}
index 637f353e7b0f03950c33fbae2666fdfa1c5f131e..930306bc3d32b5629b0c2ef44cbdd014e39bb9a8 100644 (file)
@@ -1,6 +1,8 @@
 <tal:block metal:use-macro="templates/page/macros/icing">
-<title metal:fill-slot="head_title">List of classes</title>
-<span metal:fill-slot="body_title" tal:omit-tag="python:1">List of classes</span>
+<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">
 
index fafdb2e5615986279c2b33dc0f96e4436e146689..90d926aab3c571ac954ea5f3c9185b42703fb6af 100644 (file)
@@ -5,6 +5,6 @@
  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'],
+    sort=[('-', 'activity')], group=[('+', 'priority')], filter=['status'],
     columns=['id','activity','title','creator','assignedto', 'status'],
     filterspec={'status':['-1','1','2','3','4','5','6','7']})" />
index ea8a6d11f0ceeda639c13c275bc3fd55f07e3f1f..581624c88ed6cca22ba99ed544c16fbcbe205710 100644 (file)
@@ -1,34 +1,47 @@
-<!-- dollarId: issue.index,v 1.2 2001/07/29 04:07:37 richard Exp dollar-->
+<!-- $Id: issue.index.html,v 1.29 2007-09-18 17:44:26 jpend Exp $ -->
 <tal:block metal:use-macro="templates/page/macros/icing">
-<title metal:fill-slot="head_title"> 
- List of issues - <span tal:replace="config/TRACKER_NAME" />
-</title> 
-<span metal:fill-slot="body_title" tal:omit-tag="python:1">List of issues</span>
+<title metal:fill-slot="head_title" >
+  <span tal:omit-tag="true" i18n:translate="" >List of issues</span>
+  <span tal:condition="request/dispname"
+   tal:replace="python:' - %s '%request.dispname"
+  /> - <span tal:replace="config/TRACKER_NAME" />
+</title>
+<span metal:fill-slot="body_title" tal:omit-tag="true">
+  <span tal:omit-tag="true" i18n:translate="" >List of issues</span>
+  <span tal:condition="request/dispname"
+   tal:replace="python:' - %s' % request.dispname" />
+</span>
 <td class="content" metal:fill-slot="content">
 
-<tal:block tal:condition="not:context/is_view_ok">
-You are not allowed to view this page.
-</tal:block>
+<p tal:condition="python:not (context.is_view_ok()
+ or request.user.hasRole('Anonymous'))" i18n:translate="">
+ You are not allowed to view this page.</p>
+
+<p tal:condition="python:not context.is_view_ok()
+ and request.user.hasRole('Anonymous')" i18n:translate="">
+ Please login with your username and password.</p>
 
 <tal:block tal:define="batch request/batch" tal:condition="context/is_view_ok">
  <table class="list">
   <tr>
-   <th tal:condition="request/show/priority">Priority</th>
-   <th tal:condition="request/show/id">ID</th>
-   <th tal:condition="request/show/creation">Creation</th>
-   <th tal:condition="request/show/activity">Activity</th>
-   <th tal:condition="request/show/actor">Actor</th>
-   <th tal:condition="request/show/topic">Topic</th>
-   <th tal:condition="request/show/title">Title</th>
-   <th tal:condition="request/show/status">Status</th>
-   <th tal:condition="request/show/creator">Creator</th>
-   <th tal:condition="request/show/assignedto">Assigned&nbsp;To</th>
+   <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/keyword" i18n:translate="">Keyword</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">
+ <tal:block tal:repeat="i batch" condition=true>
+  <tr tal:define="group python:[r[1] for r in request.group]"
+      tal:condition="python:group and batch.propchanged(*group)">
+   <th tal:attributes="colspan python:len(request.columns)" class="group">
+    <tal:block tal:repeat="g group">
+     <tal:block i18n:translate="" tal:content="python:str(i[g]) or '(no %s set)'%g"/>
+    </tal:block>
    </th>
   </tr>
 
@@ -42,13 +55,14 @@ You are not allowed to view this page.
        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/keyword"
+       tal:content="python:i.keyword.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"
+       i18n:translate=""
        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>
@@ -58,70 +72,87 @@ You are not allowed to view this page.
 
  </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_href(request.classname,
-         {'@startwith':prev.first, '@pagesize':prev.size})">&lt;&lt; previous</a>
+         tal:attributes="href python:request.indexargs_url(request.classname,
+         {'@startwith':prev.first, '@pagesize':prev.size})"
+         i18n:translate="">&lt;&lt; previous</a>
       &nbsp;
      </th>
-     <th tal:content="python: '%d...%d out of %d'%(batch.start,
-             batch.start+batch.length-1, batch.sequence_length)">current</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_href(request.classname,
-         {'@startwith':next.first, '@pagesize':next.size})">next &gt;&gt;</a>
+         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'})">Download as CSV</a>
+            {'@action':'export_csv'})" i18n:translate="">Download as CSV</a>
 
-<form method="GET" id="index-controls" tal:attributes="action request/classname">
- <table class="form">
-  <tr tal:condition="batch">
-   <th>Sort on:</th>
+<form method="GET" class="index-controls"
+    tal:attributes="action request/classname">
+
+ <table class="form" tal:define="n_sort python:2">
+  <tal:block tal:repeat="n python:range(n_sort)" tal:condition="batch">
+  <tr tal:define="key python:len(request.sort)>n and request.sort[n]">
+   <th>
+    <tal:block tal:condition="not:n" i18n:translate="">Sort on:</tal:block>
+   </th>
    <td>
-    <select name="@sort">
-     <option value="">- nothing -</option>
+    <select tal:attributes="name python:'@sort%d'%n">
+     <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">column</option>
+                             selected python:key and col._name == key[1]"
+             tal:content="col/_name"
+             i18n:translate="">column</option>
     </select>
    </td>
-   <th>Descending:</th>
-   <td><input type="checkbox" name="@sortdir"
-              tal:attributes="checked python:request.sort[0] == '-'"> 
+   <th i18n:translate="">Descending:</th>
+   <td><input type="checkbox" tal:attributes="name python:'@sortdir%d'%n;
+              checked python:key and key[0] == '-'">
    </td>
   </tr>
-  <tr>
-   <th>Group on:</th>
+  </tal:block>
+  <tal:block tal:repeat="n python:range(n_sort)" tal:condition="batch">
+  <tr tal:define="key python:len(request.group)>n and request.group[n]">
+   <th>
+    <tal:block tal:condition="not:n" i18n:translate="">Group on:</tal:block>
+   </th>
    <td>
-    <select name="@group">
-     <option value="">- nothing -</option>
+    <select tal:attributes="name python:'@group%d'%n">
+     <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">column</option>
+                             selected python:key and col._name == key[1]"
+             tal:content="col/_name"
+             i18n:translate="">column</option>
     </select>
    </td>
-   <th>Descending:</th>
-   <td><input type="checkbox" name="@groupdir"
-              tal:attributes="checked python:request.group[0] == '-'"> 
+   <th i18n:translate="">Descending:</th>
+   <td><input type="checkbox" tal:attributes="name python:'@groupdir%d'%n;
+              checked python:key and key[0] == '-'">
    </td>
   </tr>
+  </tal:block>
   <tr><td colspan="4">
-              <input type="submit" value="Redisplay">
+              <input type="submit" value="Redisplay" i18n:attributes="value">
               <tal:block tal:replace="structure
                 python:request.indexargs_form(sort=0, group=0)" />
   </td></tr>
@@ -131,4 +162,5 @@ You are not allowed to view this page.
 </tal:block>
 
 </td>
-</tal:block>
+</tal:block><tal:comment condition=false> vim: sw=1 ts=8 et si
+</tal:comment>
index 1cdd14f51c8f60f30c9be335b4feeb24301e78c5..41ee073895750a69432814ef07d613b420cbce72 100644 (file)
@@ -1,65 +1,89 @@
 <!-- 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:x condition="context/id"
-       replace="string:Issue ${context/id}: ${context/title}" />
-<tal:x condition="not:context/id">New Issue</tal:x>
-- <tal:x replace="config/TRACKER_NAME" />
-</title> 
-<span metal:fill-slot="body_title" tal:omit-tag="python:1">
- <tal:x tal:condition="not:context/id">New</tal:x> 
-  Issue<tal:x replace="context/id" />
-   <tal:x tal:condition="context/is_edit_ok">Editing</tal:x>
- </span>
+<title metal:fill-slot="head_title">
+<tal:block condition="context/id" i18n:translate=""
+ >Issue <tal:x tal:content="context/id" i18n:name="id"
+ />: <tal:x content="context/title" i18n:name="title"
+ /> - <tal:x content="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">
 
-<form method="POST" name="itemSynopsis" onSubmit="return submit_once()"
-      enctype="multipart/form-data" tal:attributes="action context/designator">
+<p tal:condition="python:not (context.is_view_ok()
+ or request.user.hasRole('Anonymous'))" i18n:translate="">
+ You are not allowed to view this page.</p>
+
+<p tal:condition="python:not context.is_view_ok()
+ and request.user.hasRole('Anonymous')" i18n:translate="">
+ Please login with your username and password.</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">Title</th>
+ <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">Priority</th>
+ <th class="required" i18n:translate="">Priority</th>
  <td tal:content="structure context/priority/menu">priority</td>
- <th>Status</th>
+ <th i18n:translate="">Status</th>
  <td tal:content="structure context/status/menu">status</td>
 </tr>
 
 <tr>
- <th>Superseder</th>
+ <th i18n:translate="">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', property='superseder')" />
-  <span tal:condition="context/superseder" tal:repeat="sup context/superseder">
-   <br>View: <a tal:attributes="href string:issue${sup/id}"
-                tal:content="sup/id"></a>
+  <span tal:condition="context/is_edit_ok" tal:replace="structure python:db.issue.classhelp('id,title', property='superseder')" />
+  <span tal:condition="context/superseder">
+   <br><span i18n:translate="">View:</span>
+     <a tal:repeat="sup context/superseder"
+        tal:content="python:sup['id'] + ', '*(not repeat['sup'].end)"
+        tal:attributes="href string:issue${sup/id}"></a>
   </span>
  </td>
- <th>Nosy List</th>
+ <th i18n:translate="">Nosy List</th>
  <td>
   <span tal:replace="structure context/nosy/field" />
-  <span tal:replace="structure
+  <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>Assigned To</th>
+ <th i18n:translate="">Assigned To</th>
  <td tal:content="structure context/assignedto/menu">assignedto menu</td>
- <th>Topics</th>
+ <th i18n:translate="">Keywords</th>
  <td>
-  <span tal:replace="structure context/topic/field" />
-  <span tal:replace="structure python:db.keyword.classhelp(property='topic')" />
+  <span tal:replace="structure context/keyword/field" />
+  <span tal:condition="context/is_edit_ok" tal:replace="structure python:db.keyword.classhelp(property='keyword')" />
  </td>
 </tr>
 
 <tr tal:condition="context/is_edit_ok">
- <th>Change Note</th>
+ <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>
@@ -67,7 +91,7 @@ python:db.user.classhelp('username,realname,address', property='nosy', width='60
 </tr>
 
 <tr tal:condition="context/is_edit_ok">
- <th>File</th>
+ <th i18n:translate="">File</th>
  <td colspan=3><input type="file" name="@file" size="40"></td>
 </tr>
 
@@ -77,31 +101,42 @@ python:db.user.classhelp('username,realname,address', property='nosy', width='60
   <input type="hidden" name="@template" value="item">
   <input type="hidden" name="@required" value="title,priority">
  </td>
- <td colspan=3 tal:content="structure context/submit">
-  submit button will go here
+ <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>
 
-<table class="form" tal:condition="not:context/id">
+<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" tal:content="structure string:Created on
-  <b>${context/creation}</b> by <b>${context/creator}</b>, last
-  changed <b>${context/activity}</b> by <b>${context/actor}</b>.">activity info
+<p tal:condition="context/id" i18n:translate="">
+ Created on <b tal:content="context/creation" i18n:name="creation" />
+ by <b tal:content="context/creator" i18n:name="creator" />,
+ last changed <b content="context/activity" i18n:name="activity" />
+ by <b tal:content="context/actor" i18n:name="actor" />.
 </p>
 
 <table class="files" tal:condition="context/files">
- <tr><th colspan="4" class="header">Files</th></tr>
- <tr><th>File name</th><th>Uploaded</th><th>Type</th><th>Edit</th></tr>
+ <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"
@@ -115,23 +150,33 @@ python:db.user.classhelp('username,realname,address', property='nosy', width='60
   <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@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">Messages</th></tr>
+ <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}"
-          tal:content="string:msg${msg/id} (view)"></a></th>
-   <th tal:content="string:Author: ${msg/author}">author</th>
-   <th tal:content="string:Date: ${msg/date}">date</th>
+    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@messages" tal:attributes="value msg/id">
      <input type="hidden" name="@action" value="edit">
-     <input type="submit" value="remove">
+     <input type="submit" value="remove" i18n:attributes="value">
     </form>
    </th>
   </tr>
@@ -145,6 +190,8 @@ python:db.user.classhelp('username,realname,address', property='nosy', width='60
 
 <tal:block tal:condition="context/id" tal:replace="structure context/history" />
 
+</div>
+
 </td>
 
 </tal:block>
index 69ec8b6a4ff43de2163bf4c0d07bf060a714df27..cff5122b7fa348a9b98131b0e4669298d9bcc8fe 100644 (file)
@@ -1,31 +1,41 @@
 <tal:block metal:use-macro="templates/page/macros/icing">
-<title metal:fill-slot="head_title">Issue searching</title>
-<span metal:fill-slot="body_title" tal:omit-tag="python:1">Issue searching</span>
+<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" tal:attributes="action request/classname">
-
+<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';
+   sort_on python:request.sort and request.sort[0] or nothing;
+   sort_desc python:sort_on and sort_on[0] == '-';
+   sort_on python:(sort_on and sort_on[1]) or 'activity';
+   group_on python:request.group and request.group[0] or nothing;
+   group_desc python:group_on and group_on[0] == '-';
+   group_on python:(group_on and group_on[1]) or 'priority';
 
    search_input templates/page/macros/search_input;
+   search_date templates/page/macros/search_date;
    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_select templates/page/macros/search_select;
+   search_select_translated templates/page/macros/search_select_translated;
+   search_multiselect templates/page/macros/search_multiselect;">
 
 <tr>
  <th class="header">&nbsp;</th>
- <th class="header">Filter on</th>
- <th class="header">Display</th>
- <th class="header">Sort on</th>
- <th class="header">Group on</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>All text*:</th>
+  <th i18n:translate="">All text*:</th>
   <td metal:use-macro="search_input"></td>
   <td>&nbsp;</td>
   <td>&nbsp;</td>
 </tr>
 
 <tr tal:define="name string:title">
-  <th>Title:</th>
+  <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;
+<tr tal:define="name string:keyword;
                 db_klass string:keyword;
                 db_content string:name;">
-  <th>Topic:</th>
-  <td metal:use-macro="search_select"></td>
+  <th i18n:translate="">Keyword:</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:id">
-  <th>ID:</th>
+  <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>
@@ -59,8 +72,8 @@
 </tr>
 
 <tr tal:define="name string:creation">
-  <th>Creation Date:</th>
-  <td metal:use-macro="search_input"></td>
+  <th i18n:translate="">Creation Date:</th>
+  <td metal:use-macro="search_date"></td>
   <td metal:use-macro="column_input"></td>
   <td metal:use-macro="sort_input"></td>
   <td metal:use-macro="group_input"></td>
 
 <tr tal:define="name string:creator;
                 db_klass string:user;
-                db_content string:username;">
-  <th>Creator:</th>
+                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"
+    <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>
 </tr>
 
 <tr tal:define="name string:activity">
-  <th>Activity:</th>
-  <td metal:use-macro="search_input"></td>
+  <th i18n:translate="">Activity:</th>
+  <td metal:use-macro="search_date"></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">
-  <th>Actor:</th>
+<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"
+    <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>
 <tr tal:define="name string:priority;
                 db_klass string:priority;
                 db_content string:name;">
-  <th>Priority:</th>
-  <td metal:use-macro="search_select">
-    <option metal:fill-slot="extra_options" value="-1"
+  <th i18n:translate="">Priority:</th>
+  <td metal:use-macro="search_select_translated">
+    <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>
 <tr tal:define="name string:status;
                 db_klass string:status;
                 db_content string:name;">
-  <th>Status:</th>
-  <td metal:use-macro="search_select">
+  <th i18n:translate="">Status:</th>
+  <td metal:use-macro="search_select_translated">
     <tal:block metal:fill-slot="extra_options">
-      <option value="-1,1,2,3,4,5,6,7"
+      <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"
+      <option value="-1" i18n:translate=""
               tal:attributes="selected python:value == '-1'">not selected</option>
-    </tal:block>    
+    </tal:block>
   </td>
   <td metal:use-macro="column_input"></td>
   <td metal:use-macro="sort_input"></td>
 
 <tr tal:define="name string:assignedto;
                 db_klass string:user;
-                db_content string:username;">
-  <th>Assigned to:</th>
+                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">assigned to me</option>
-      <option value="-1" tal:attributes="selected python:value == '-1'">unassigned</option>
+      <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>
 </tr>
 
 <tr>
-<th>Pagesize:</th>
+ <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>Start With:</th>
+<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>Sort Descending:</th>
+<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">
+           tal:attributes="checked sort_desc">
 </td>
 </tr>
 
 <tr>
-<th>Group Descending:</th>
+<th i18n:translate="">Group Descending:</th>
 <td><input type="checkbox" name="@groupdir"
-           tal:attributes="checked python:request.group[0] == '-'">
+           tal:attributes="checked group_desc">
 </td>
 </tr>
 
-<tr>
-<th>Query name**:</th>
-<td><input name="@queryname"
-           tal:attributes="value request/form/@queryname/value | default"></td>
+<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>
    &nbsp;
    <input type="hidden" name="@action" value="search">
   </td>
-  <td><input type="submit" value="Search"></td>
+  <td><input type="submit" value="Search" i18n:attributes="value"></td>
 </tr>
 
 <tr><td>&nbsp;</td>
  <td colspan="4" class="help">
-   *: The "all text" field will look in message bodies and issue titles<br>
+  <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>
index b15b6cd6b5a9c21cb1ad5a34c0620a8a096f661c..cf1e26a4c8e0571a138f404d7de2dd47aa9f079d 100644 (file)
@@ -1,12 +1,14 @@
 <!-- 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">Keyword editing</title>
-<span metal:fill-slot="body_title" tal:omit-tag="python:1">Keyword editing</span>
+<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">Existing Keywords</th></tr>
+ <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">
   </td>
  </tr>
  <tr>
-  <td colspan="4" style="border-top: 1px solid gray">
+  <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">
+<p class="help" tal:condition="not:context/id" i18n:translate="">
  To create a new keyword, enter it below and click "Submit New Entry".
 </p>
 
@@ -32,7 +34,7 @@
 
  <table class="form">
   <tr>
-   <th>Keyword</th>
+   <th i18n:translate="">Keyword</th>
    <td tal:content="structure context/name/field">name</td>
   </tr>
 
index a17dfbf09facb5d549ee138bf9362c4ae059ab5f..02405dc780570d1f152291b385dd004598c6ea00 100644 (file)
@@ -1,13 +1,13 @@
 <tal:block metal:use-macro="templates/page/macros/icing">
-<title metal:fill-slot="head_title"
- List of messages - 
<span tal:replace="config/TRACKER_NAME" />
-</title> 
-<span metal:fill-slot="body_title" tal:omit-tag="python:1">Message listing</span>
+<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 class="messages" tal:condition="request/filter">
- <tr><th colspan=2 class="header">Messages</th></tr>
- <tal:block tal:repeat="msg context/list">
+<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>
@@ -16,6 +16,9 @@
    <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>
 
index d559ab184d5dfe225956182b1e5b4a72fee1fe7d..e755174da6928cc2e62475c74174d8fd9b3296fc 100644 (file)
@@ -1,43 +1,68 @@
 <!-- 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"> 
-<span tal:condition="context/id" tal:replace="string:Message ${context/id}" />
-<tal:x tal:condition="not:context/id">New Message</tal:x> 
-- <span tal:replace="config/TRACKER_NAME" />
-</title> 
-<span metal:fill-slot="body_title" tal:omit-tag="python:1">
-  Message<span tal:replace="context/id" />
-   <tal:x tal:condition="context/is_edit_ok">Editing</tal:x>
- </span>
+<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="python:not (context.is_view_ok()
+ or request.user.hasRole('Anonymous'))" i18n:translate="">
+ You are not allowed to view this page.</p>
+
+<p tal:condition="python:not context.is_view_ok()
+ and request.user.hasRole('Anonymous')" i18n:translate="">
+ Please login with your username and password.</p>
+
+<div tal:condition="context/is_view_ok">
 <table class="form">
 
 <tr>
- <th>Author</th>
+ <th i18n:translate="">Author</th>
  <td tal:content="context/author"></td>
 </tr>
 
 <tr>
- <th>Recipients</th>
+ <th i18n:translate="">Recipients</th>
  <td tal:content="context/recipients"></td>
 </tr>
 
 <tr>
- <th>Date</th>
+ <th i18n:translate="">Date</th>
  <td tal:content="context/date"></td>
 </tr>
 </table>
 
 <table class="messages">
- <tr><th colspan=2 class="header">Content</th></tr>
+ <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">Files</th></tr>
- <tr><th>File name</th><th>Uploaded</th></tr>
+ <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}"
@@ -51,6 +76,8 @@
 </table>
 
 <tal:block tal:replace="structure context/history" />
+
+</div>
 </td>
 
 </tal:block>
index 1ff5a1bdb1d891509d2e15b104051a2d6421a17d..cd64875fab5ed97ccd01823bbd700d358e8d5de5 100644 (file)
@@ -1,34 +1,60 @@
-<tal:block metal:define-macro="icing">
-<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
-                               "http://www.w3.org/TR/html4/strict.dtd">
+<!-- vim:sw=2 sts=2
+--><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>
-<meta http-equiv="Content-Type" content="text/html; charset=utf-8;">
-
 <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>
+<metal:x define-slot="more-javascript" />
 
 </head>
 <body class="body">
 
-<table class="body">
+<table class="body"
+ tal:define="
+kw_edit python:request.user.hasPermission('Edit', 'keyword');
+kw_create python:request.user.hasPermission('Create', 'keyword');
+kw_edit_link python:kw_edit and db.keyword.list();
+columns string:id,activity,title,creator,status;
+columns_showall string:id,activity,title,creator,assignedto,status;
+status_notresolved string:-1,1,2,3,4,5,6,7;
+"
+>
 
 <tr>
  <td class="page-header-left">&nbsp;</td>
  <td class="page-header-top">
-  <h2><span metal:define-slot="body_title">body title</span></h2>
+   <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"
+             tal:attributes="value columns_showall"
+             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"
+              tal:attributes="value request/search_text | default" />
+       <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">
-   <b>Your Queries</b> (<a href="query?@template=edit">edit</a>)<br>
+  <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}"
+    <a href="#" 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>Issues</b><br>
-    <a tal:condition="python:request.user.hasPermission('Edit', 'issue')"
-      href="issue?@template=item">Create New<br></a>
-    <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">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">Show All</a><br>
-    <a href="issue?@template=search">Search</a><br>
-    <input type="submit" style="padding: 0" value="Show issue:"><input size="4" type="text" name="@number">
+    <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="#"
+       tal:attributes="href python:request.indexargs_url('issue', {
+      '@sort': '-activity',
+      '@group': 'priority',
+      '@filter': 'status,assignedto',
+      '@columns': columns,
+      '@search_text': '',
+      'status': status_notresolved,
+      'assignedto': '-1',
+      '@dispname': i18n.gettext('Show Unassigned'),
+     })"
+       i18n:translate="">Show Unassigned</a><br>
+    <a href="#"
+       tal:attributes="href python:request.indexargs_url('issue', {
+      '@sort': '-activity',
+      '@group': 'priority',
+      '@filter': 'status',
+      '@columns': columns_showall,
+      '@search_text': '',
+      'status': status_notresolved,
+      '@dispname': i18n.gettext('Show All'),
+     })"
+       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')">
-   <b>Keywords</b><br>
-   <a href="keyword?@template=item">Create New<br></a>
-   <a tal:condition="db/keyword/list"
-      href="keyword?@template=item">Edit Existing<br></a>
+     tal:condition="python:kw_edit or kw_create">
+   <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="kw_edit_link">
+    <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>Administration</b><br>
-   <tal:block tal:condition="python:request.user.hasPermission('Edit', None)">
-    <a href="home?@template=classlist">Class List</a><br>
-   </tal:block>
-   <a tal:condition="python:request.user.hasPermission('View', 'user')
-                            or request.user.hasPermission('Edit', 'user')"
-      href="user" >User List</a><br>
-   <a tal:condition="python:request.user.hasPermission('Edit', 'user')"
-      href="user?@template=item">Add User</a>
+   <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>Login</b><br>
+    <b i18n:translate="">Login</b><br>
     <input size="10" name="__login_name"><br>
     <input size="10" type="password" name="__login_password"><br>
-    <input type="submit" name="@action" value="Login"><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('Web Registration')">Register<br></a>
-    <a href="user?@template=forgotten">Lost&nbsp;your&nbsp;login?</a><br>
+       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>Hello,</b> <b tal:content="request/user/username">username</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}">My Issues</a><br>
-   <a tal:attributes="href string:user${request/user/id}">My Details</a><br>
-   <a tal:attributes="href python:request.indexargs_href('',
-       {'@action':'logout'})">Logout</a>
+   <b i18n:translate="">Hello, <span i18n:name="user"
+    tal:replace="python:request.user.username.plain(escape=1)">username</span></b><br>
+    <a href="#"
+       tal:attributes="href python:request.indexargs_url('issue', {
+      '@sort': '-activity',
+      '@group': 'priority',
+      '@filter': 'status,assignedto',
+      '@columns': 'id,activity,title,creator,status',
+      '@search_text': '',
+      'status': status_notresolved,
+      'assignedto': request.user.id,
+      '@dispname': i18n.gettext('Your Issues'),
+     })"
+    i18n:translate="">Your Issues</a><br>
+   <a href="#" tal:attributes="href string:user${request/user/id}"
+    i18n:translate="">Your Details</a><br>
+   <a href="#" tal:attributes="href python:request.indexargs_url('',
+       {'@action':'logout'})" i18n:translate="">Logout</a>
   </p>
   <p class="userblock">
-   <b>Help</b><br>
-   <a href="http://roundup.sourceforge.net/doc-0.7/">Roundup docs</a>
+   <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">error</p>
-  <p tal:condition="options/ok_message | nothing" class="ok-message"
-     tal:repeat="m options/ok_message" tal:content="structure m">error</p>
+     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>
 </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.
+-->
+
+<!-- creates a th and a label: -->
+<th metal:define-macro="th_label"
+    tal:define="required required | python:[]"
+    tal:attributes="class python:(name in required) and 'required' or nothing">
+  <label tal:attributes="for name" tal:content="label" i18n:translate="">text</label>
+       <metal:x define-slot="behind_the_label" />
+</th>
+
 <td metal:define-macro="search_input">
   <input tal:attributes="value python:request.form.getvalue(name) or nothing;
-                         name name">
+                         name name;
+                         id name">
+</td>
+
+<td metal:define-macro="search_date">
+  <input tal:attributes="value python:request.form.getvalue(name) or nothing;
+                         name name;
+                         id name">
+  <a class="classhelp"
+        tal:attributes="href python:'''javascript:help_window('issue?@template=calendar&property=%s&form=itemSynopsis', 300, 200)'''%name">(cal)</a>
+</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;
+                         id name">
+  <span tal:replace="structure python:db.issue.classhelp(columns,
+                                      property=name)" />
 </td>
 
 <td metal:define-macro="search_select">
-  <select tal:attributes="name name"
+  <select tal:attributes="name name; id name"
           tal:define="value python:request.form.getvalue(name)">
-    <option value="">don't care</option>
-    <tal:block metal:define-slot="extra_options"></tal:block>
-    <option value="">------------</option>      
+    <option value="" i18n:translate="">don't care</option>
+    <metal:slot define-slot="extra_options" />
+    <option value="" i18n:translate="" disabled="disabled">------------</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>
 
+<!-- like search_select, but translates the further values.
+Could extend it (METAL 1.1 attribute "extend-macro")
+-->
+<td metal:define-macro="search_select_translated">
+  <select tal:attributes="name name; id name"
+          tal:define="value python:request.form.getvalue(name)">
+    <option value="" i18n:translate="">don't care</option>
+    <metal:slot define-slot="extra_options" />
+    <option value="" i18n:translate="" disabled="disabled">------------</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]"
+                                               i18n:translate=""></option>
+  </select>
+</td>
+
+<!-- currently, there is no convenient API to get a list of all roles -->
+<td metal:define-macro="search_select_roles"
+         tal:define="onchange onchange | nothing">
+  <select name=roles id=roles tal:attributes="onchange onchange">
+    <option value="" i18n:translate="">don't care</option>
+    <option value="" i18n:translate="" disabled="disabled">------------</option>
+    <option value="User">User</option>
+    <option value="Admin">Admin</option>
+    <option value="Anonymous">Anonymous</option>
+  </select>
+</td>
+
+<td metal:define-macro="search_multiselect">
+  <input tal:attributes="value python:request.form.getvalue(name) or nothing;
+                         name name;
+                         id 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)">
+     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:value == s.id" />
-  <label tal:attributes="for string:$name-${s/id}" tal:content="s/name" />
+  <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">no value</label>
+  <label tal:attributes="for string:$name--1" i18n:translate="">no value</label>
  </li>
  </ul>
 </td>
          tal:attributes="value name;
                          checked python:name == group_on">
 </td>
-<!-- SHA: 9defd15b86478f539e44f06b9548340e239d7320 -->
+
+<!--
+The following macros are intended for user editing.
+
+The invoking context must define a "name" variable which names the
+property being searched; the "edit_ok" variable tells whether the
+current user is allowed to edit.
+
+See user.item.html in the classic template for examples.
+-->
+<script metal:define-macro="user_utils" type="text/javascript" src="@@file/user_utils.js"></script>
+
+<!-- src: value will be re-used for other input fields -->
+<input metal:define-macro="user_src_input"
+    type="text" tal:attributes="onblur python:edit_ok and 'split_name(this)';
+    id name; name name; value value; readonly not:edit_ok"
+    value="heinz.kunz">
+<!-- normal: no re-using -->
+<input metal:define-macro="user_normal_input" type="text"
+    tal:attributes="id name; name name; value value; readonly not:edit_ok"
+    value="heinz">
+<!-- password: type; no initial value -->
+    <input metal:define-macro="user_pw_input" type="password"
+    tal:attributes="id name; name name; readonly not:edit_ok" value="">
+    <input metal:define-macro="user_confirm_input" type="password"
+    tal:attributes="id name; name string:@confirm@$name; readonly not:edit_ok" value="">
+
index b36559d39da4b3a420b251aabbb69534c13a7a55..2c586f13fb7d21ef233b4d70e1eefe0d7e9297f2 100644 (file)
@@ -1,18 +1,15 @@
 <!-- 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"> 
-"Your Queries" Editing
-- <span tal:replace="config/TRACKER_NAME" />
-</title> 
-<span metal:fill-slot="body_title" tal:omit-tag="python:1">
- "Your Queries" Editing
-</span>
+<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">
-You are not allowed to edit queries.
-</span>
+<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
@@ -28,10 +25,10 @@ function retire(qid) {
 <table class="list" width="100%"
        tal:define="uid request/user/id; mine request/user/queries">
 
-<tr><th>Query</th>
-    <th>Include in "Your Queries"</th>
-    <th>Edit</th>
-    <th>Private to you?</th>
+<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>
 
@@ -44,59 +41,67 @@ function retire(qid) {
  <td metal:define-macro="include">
   <select tal:condition="python:query.id not in mine"
           tal:attributes="name string:user${uid}@add@queries">
-    <option value="">leave out</option>
-    <option tal:attributes="value query/id">include</option>
+    <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@queries">
-    <option value="">leave in</option>
-    <option tal:attributes="value query/id">remove</option>
+    <option value="" i18n:translate="">leave in</option>
+    <option tal:attributes="value query/id" i18n:translate="">remove</option>
   </select>
  </td>
 
- <td colspan="3">[query is retired]</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">
+<tr tal:repeat="query mine">
<tal:block condition="not:query/is_retired">
  <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}">edit</a></td>
+ <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">yes</option>
+           value uid" i18n:translate="">yes</option>
    <option tal:attributes="selected python:query.private_for == None"
-           value="-1">no</option>
+           value="-1" i18n:translate="">no</option>
   </select>
  </td>
 
  <td>
-  <input type="button" value="Delete"
+  <input type="button" value="Delete" i18n:attributes="value"
   tal:attributes="onClick python:'''retire('%s')'''%query.id">
   </td>
+  </tal:block>
 </tr>
 
 <tr tal:define="queries python:db.query.filter(filterspec={'private_for':None})"
      tal:repeat="query queries">
+ <tal:block condition="python: query.creator != uid">
  <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">[not yours to edit]</td>
+
+ <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>
+ </tal:block>
 </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">
+   <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>
index 5e969d33bcab3ebab49f4449b88d5407a419a157..453e13d00b9357f8ebc12ea96f6fe95ecf4d29aa 100644 (file)
@@ -1,3 +1,3 @@
 <!-- query.item -->
-<span tal:content="structure context/renderQueryForm" />
+<span tal:replace="structure context/renderQueryForm" />
 
index ab1d615b17de9f30721a1845de991cb9113d13ce..72e7ffce5507b248b162884836192b4aa89fb3bf 100644 (file)
@@ -38,12 +38,35 @@ td.sidebar {
     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;
@@ -67,6 +90,12 @@ td.sidebar p.userblock {
   background-color: #eef;
 }
 
+.form-small {
+  padding: 0;
+  font-size: 75%;
+}
+
+
 td.content {
   padding: 1px 5px 1px 5px;
   vertical-align: top;
@@ -89,12 +118,16 @@ p.error-message {
   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;
+    padding: 0;
     list-style: none;
 }
 ul.search-checkboxes > li {
@@ -388,3 +421,13 @@ table.otherinfo th {
   font-weight: bold;
   text-align: left;
 }
+input[type="text"]:focus,
+input[type="checkbox"]:focus,
+input[type="radio"]:focus,
+input[type="password"]:focus,
+textarea:focus, select:focus {
+  background-color: #ffffc0;
+}
+
+/* vim: sts=2 sw=2 et
+*/
index 121fcc5d59ca990db1d63e05af5ce2dffde17f94..3f0a472e295f3ddbc7d58f4534f35a7969849be1 100644 (file)
@@ -1,17 +1,19 @@
 <!-- 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">Password reset request</title>
-<span metal:fill-slot="body_title" tal:omit-tag="python:1">Password reset request</span>
+<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>You have two options if you have forgotten your password. If you 
-know the email address you registered with, enter it below.</p>
+<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>Email Address:</th>
+        <th i18n:translate="">Email Address:</th>
         <td><input name="address"></td>
       </tr>
       <tr>
@@ -19,22 +21,23 @@ know the email address you registered with, enter it below.</p>
         <td>
           <input type="hidden" name="@action" value="passrst">
           <input type="hidden" name="@template" value="forgotten">
-          <input type="submit" value="Request password reset">
+          <input type="submit" value="Request password reset"
+           i18n:attributes="value">
         </td>
       </tr>
 </table>
 
-<p>Or, if you know your username, then enter it below.</p>
+<p i18n:translate="">Or, if you know your username, then enter it below.</p>
 
 <table class="form">
- <tr><th>Username:</th> <td><input name="username"></td> </tr>
- <tr><td></td><td><input type="submit" value="Request password reset"></td></tr>
+ <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>A confirmation email will be sent to you - please follow the
-instructions
-within it to complete the reset process.</p>
+<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>
diff --git a/templates/classic/html/user.help-search.html b/templates/classic/html/user.help-search.html
new file mode 100644 (file)
index 0000000..8436897
--- /dev/null
@@ -0,0 +1,85 @@
+<html
+  tal:define="form request/form/form/value;
+  field request/form/property/value"
+  >
+  <head>
+    <title>Search input for user helper</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  = parent.opener.document.${form};
+        field  = '${field}';
+        //-->">
+    </script>
+    <script type="text/javascript" src="@@file/help_controls.js"></script>
+    <link rel="stylesheet" type="text/css" href="@@file/style.css" />
+  </head>
+  <body onload="parent.submit.url='...'"
+    tal:define="
+qs request/env/QUERY_STRING;
+qs python:'&'.join([a for a in qs.split('&') if not a.startswith('@template=')])"
+>
+    <pre tal:content="request/env/QUERY_STRING" tal:condition=false />
+    <form method="GET" name="itemSynopsis"
+      target="list"
+      tal:attributes="action request/classname"
+      tal:define="
+      property request/form/property/value;
+   cols python:request.columns or 'id username address realname roles'.split();
+   sort_on request/sort | nothing;
+   sort_desc python:sort_on and request.sort[0][0] == '-';
+   sort_on python:sort_on and request.sort[0][1] or 'lastname';
+
+   search_input templates/page/macros/search_input;
+   search_select templates/page/macros/search_select;
+   search_select_roles templates/page/macros/search_select_roles;
+   required python:[];
+   th_label templates/page/macros/th_label;
+   ">
+   <input type="hidden" name="@template" value="help-list">
+   <input type="hidden" name="property" value="" tal:attributes="value property">
+   <input type="hidden" name="form" value="" tal:attributes="value request/form/form/value">
+   <table>
+<tr tal:define="name string:username; label string:Username:">
+  <th metal:use-macro="th_label">Name</th>
+  <td metal:use-macro="search_input"><input type=text></td>
+</tr>
+
+<tr tal:define="name string:phone; label string:Phone number">
+  <th metal:use-macro="th_label">Phone</th>
+  <td metal:use-macro="search_input"><input type=text></td>
+</tr>
+
+<tr tal:define="name string:roles;
+                onchange string:this.form.submit();
+                label string:Roles:"
+                >
+  <th metal:use-macro="th_label">role</th>
+  <td metal:use-macro="search_select_roles">
+    <select>
+      <option value="">jokester</option>
+    </select>
+  </td>
+</tr>
+
+<tr>
+  <td>&nbsp;</td>
+  <td>
+    <input type="hidden" name="@action" value="search">
+    <input type="submit" value="Search" i18n:attributes="value">
+    <input type="reset">
+    <input type="hidden" value="username,realname,phone,organisation,roles" name="properties">
+    <input type="text" name="@pagesize" id="sp-pagesize" value="25" size="2">
+    <label for="sp-pagesize" i18n:translate="">Pagesize</label>
+  </td>
+</tr>
+
+   </table>
+
+</form>
+<pre tal:content="request" tal:condition=false />
+<script type="text/javascript"><!--
+  focus2id('username');
+//--></script>
+  </body>
+</html>
diff --git a/templates/classic/html/user.help.html b/templates/classic/html/user.help.html
new file mode 100644 (file)
index 0000000..b338c2b
--- /dev/null
@@ -0,0 +1,49 @@
+<!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;
+qs request/env/QUERY_STRING;
+qs python:'&'.join([a for a in qs.split('&') if not a.startswith('@template=')]);
+form request/form/form/value;
+field 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><tal:x i18n:translate=""><tal:x i18n:name="property"
+       tal:content="property" i18n:translate="" /> help - <span i18n:name="tracker"
+              tal:replace="config/TRACKER_NAME" /></tal:x></title>
+      <script language="Javascript" type="text/javascript"
+             tal:condition=false
+          tal:content="structure string:<!--
+          // this is the name of the field in the original form that we're working on
+          form  = window.opener.document.${form};
+          field  = '${field}';
+          //-->">
+      </script>
+      <script src="@@file/help_controls.js"
+     tal:condition=false type="text/javascript"><!--
+      //--></script>
+      </tal:block>
+  </head>
+<frameset rows="123,*,62">
+  <frame src="#" tal:attributes="src string:?@template=help-search&${qs}" name="search">
+  <!-- for search results: help-list -->
+  <frame
+  tal:attributes="src string:?@template=help-empty&${qs}"
+  name="list">
+  <frame
+  tal:attributes="src string:?@template=help-submit&${qs}"
+  name="submit">
+  <!-- -->
+</frameset>
+<noframes>
+  <body>
+<p i18n:translate="">
+Your browser is not capable of using frames; you should be redirected immediately,
+or visit <a href="#" tal:attributes="href string:?${qs}&template=help-noframes"
+i18n:name="link">this link</a>.
+</p>
+</body>
+</noframes>
+
+</html>
index b1011bd28c8a77d23d3d03963dbecbf1bce07768..021e2b808a9f31423a9cb1a3e0a014bf044ffd9b 100644 (file)
@@ -1,24 +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">User listing</title>
-<span metal:fill-slot="body_title" tal:omit-tag="python:1">User listing</span>
+<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">
-You are not allowed to view this page.
-</span>
+<span tal:condition="python:not (context.is_view_ok()
+ or request.user.hasRole('Anonymous'))"
+ i18n:translate="">You are not allowed to view this page.</span>
+
+<span tal:condition="python:not context.is_view_ok()
+ and request.user.hasRole('Anonymous')"
+ i18n:translate="">Please login with your username and password.</span>
 
 <table width="100%" tal:condition="context/is_view_ok" class="list">
 <tr>
- <th>Username</th>
- <th>Real name</th>
- <th>Organisation</th>
- <th>Email address</th>
- <th>Phone number</th>
- <th tal:condition="context/is_edit_ok">Retire</th>
+ <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>
-<tr tal:repeat="user context/list"
-    tal:attributes="class python:['normal', 'alt'][repeat['user'].index%6/3]">
+<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>
@@ -28,10 +34,11 @@ You are not allowed to view this page.
  <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">
-   retire</a>
+  <a tal:attributes="href string:user${user/id}?@action=retire&@template=index"
+   i18n:translate="">retire</a>
  </td>
 </tr>
+</tal:block>
 </table>
 </td>
 
index 5d89677d17eaa150f5db6c57b7525d67e9bfb3dd..bafccd260b8f7196cc95e00abecbee91bde30a4c 100644 (file)
 <!-- 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"> 
-<span tal:condition="context/id"
-      tal:replace="string:User ${context/id}: ${context/username}" />
-<tal:x tal:condition="not:context/id">New User</tal:x> 
-- <span tal:replace="config/TRACKER_NAME" />
-</title> 
-<span metal:fill-slot="body_title" tal:omit-tag="python:1">
-  User<span tal:replace="context/id" />
-   <tal:x tal:condition="context/is_edit_ok">Editing</tal:x>
- </span>
+<tal:doc metal:use-macro="templates/page/macros/icing"
+define="edit_ok context/is_edit_ok"
+>
+<title metal:fill-slot="head_title">
+<tal:if condition="context/id" i18n:translate=""
+ >User <tal:x content="context/id" i18n:name="id"
+ />: <tal:x content="context/username" i18n:name="title"
+ /> - <tal:x content="config/TRACKER_NAME" i18n:name="tracker"
+/></tal:if>
+<tal:if condition="not:context/id" i18n:translate=""
+ >New User - <span tal:replace="config/TRACKER_NAME" i18n:name="tracker"
+/></tal:if>
+</title>
+<metal:slot fill-slot="more-javascript">
+<script metal:use-macro="templates/page/macros/user_utils"></script>
+<script type="text/javascript" src="@@file/help_controls.js"></script>
+</metal:slot>
+<tal:block metal:fill-slot="body_title"
+  define="edit_ok context/is_edit_ok">
+ <span tal:condition="python: not (context.id or edit_ok)"
+  tal:omit-tag="python:1" i18n:translate="">New User</span>
+ <span tal:condition="python: not context.id and edit_ok"
+  tal:omit-tag="python:1" i18n:translate="">New User Editing</span>
+ <span tal:condition="python: context.id and not 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 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">
-<span tal:condition="python:not (context.is_view_ok() or context.is_edit_ok())">
-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">
+<p tal:condition="python:not (context.is_view_ok()
+ or request.user.hasRole('Anonymous'))" i18n:translate="">
You are not allowed to view this page.</p>
 
-<table class="form">
- <tr>
-  <th>Name</th>
-  <td tal:content="structure context/realname/field">realname</td>
- </tr>
- <tr>
-  <th>Login Name</th>
-  <td tal:content="structure context/username/field">username</td>
- </tr>
- <tr>
-  <th>Login Password</th>
-  <td tal:content="structure context/password/field">password</td>
- </tr>
- <tr>
-  <th>Confirm Password</th>
-  <td tal:content="structure context/password/confirm">password</td>
- </tr>
- <tr tal:condition="python:request.user.hasPermission('Web Roles')">
-  <th>Roles</th>
-  <td tal:condition="context/id"
-      tal:content="structure context/roles/field">roles</td>
-  <td tal:condition="not:context/id">
-   <input name="roles" tal:attributes="value db/config/NEW_WEB_USER_ROLES">
-   (to give the user more than one role, enter a comma,separated,list)
+<p tal:condition="python:not context.is_view_ok()
+ and request.user.hasRole('Anonymous')" i18n:translate="">
+ Please login with your username and password.</p>
+
+<div tal:condition="context/is_view_ok">
+
+<form method="POST"
+      name="itemSynopsis"
+      tal:define="required python:'username address'.split()"
+      enctype="multipart/form-data"
+      tal:attributes="action context/designator;
+      onSubmit python:'return checkRequiredFields(\'%s\')'%'\', \''.join(required);
+      ">
+<table class="form" tal:define="
+  th_label templates/page/macros/th_label;
+  src_input templates/page/macros/user_src_input;
+  normal_input templates/page/macros/user_normal_input;
+  pw_input templates/page/macros/user_pw_input;
+  confirm_input templates/page/macros/user_confirm_input;
+  edit_ok context/is_edit_ok;
+  ">
+ <tr tal:define="name string:realname; label string:Name; value context/realname; edit_ok edit_ok">
+  <th metal:use-macro="th_label">Name</th>
+  <td><input name="realname" metal:use-macro="src_input"></td>
+ </tr>
+ <tr tal:define="name string:username; label string:Login Name; value context/username">
+   <th metal:use-macro="th_label">Login Name</th>
+   <td><input metal:use-macro="src_input"></td>
+ </tr>
+ <tal:if condition="edit_ok">
+ <tr tal:define="name string:password; label string:Login Password">
+  <th metal:use-macro="th_label">Login Password</th>
+  <td><input metal:use-macro="pw_input" type="password"></td>
+ </tr>
+ <tr tal:define="name string:password; label string:Confirm Password">
+  <th metal:use-macro="th_label">Confirm Password</th>
+  <td><input metal:use-macro="confirm_input" type="password"></td>
+ </tr>
+ </tal:if>
+ <tal:if condition="python:request.user.hasPermission('Web Roles')">
+ <tr tal:define="name string:roles; label string:Roles;">
+  <th><label for="roles" i18n:translate="">Roles</label></th>
+  <td tal:define="gips context/id">
+    <tal:subif condition=gips define="value context/roles">
+      <input metal:use-macro="normal_input">
+    </tal:subif>
+    <tal:subif condition="not:gips" define="value db/config/NEW_WEB_USER_ROLES">
+      <input metal:use-macro="normal_input">
+    </tal:subif>
+   <tal:block i18n:translate="">(to give the user more than one role,
+    enter a comma,separated,list)</tal:block>
   </td>
  </tr>
- <tr>
-  <th>Phone</th>
-  <td tal:content="structure context/phone/field">phone</td>
+ </tal:if>
+
+ <tr tal:define="name string:phone; label string:Phone; value context/phone">
+  <th metal:use-macro="th_label">Phone</th>
+  <td><input name="phone" metal:use-macro="normal_input"></td>
  </tr>
- <tr>
-  <th>Organisation</th>
-  <td tal:content="structure context/organisation/field">organisation</td>
+
+ <tr tal:define="name string:organisation; label string:Organisation; value context/organisation">
+  <th metal:use-macro="th_label">Organisation</th>
+  <td><input name="organisation" metal:use-macro="normal_input"></td>
  </tr>
- <tr>
-  <th>Timezone</th>
-  <td tal:content="structure context/timezone/field">timezone</td>
+
+ <tr tal:condition="python:edit_ok or context.timezone"
+     tal:define="name string:timezone; label string:Timezone; value context/timezone">
+  <th metal:use-macro="th_label">Timezone</th>
+  <td><input name="timezone" metal:use-macro="normal_input">
+   <tal:block tal:condition="edit_ok" 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>E-mail address</th>
-  <td tal:content="structure context/address/field">address</td>
+
+ <tr tal:define="name string:address; label string:E-mail address; value context/address">
+  <th metal:use-macro="th_label">E-mail address</th>
+  <td tal:define="mailto python:context.address.field(id='address');
+         mklink python:mailto and not edit_ok">
+      <a href="mailto:calvin@the-z.org"
+                 tal:attributes="href string:mailto:$value"
+                 tal:content="value"
+          tal:condition="python:mklink">calvin@the-z.org</a>
+      <tal:if condition=edit_ok>
+      <input metal:use-macro="src_input" value="calvin@the-z.org">
+      </tal:if>
+      &nbsp;
+  </td>
  </tr>
+
  <tr>
-  <th>Alternate E-mail addresses<br>One address per line</th>
-  <td tal:content="structure context/alternate_addresses/multiline">alternate_addresses</td>
+  <th><label for="alternate_addresses" i18n:translate="">Alternate E-mail addresses<br>One address per line</label></th>
+  <td>
+    <textarea rows=5 cols=40 tal:replace="structure context/alternate_addresses/multiline">nobody@nowhere.org
+anybody@everywhere.net
+(alternate_addresses)
+    </textarea>
+  </td>
  </tr>
 
- <tr>
+ <tr tal:condition="edit_ok">
   <td>
    &nbsp;
    <input type="hidden" name="@template" value="item">
-   <input type="hidden" name="@required" value="username,address">
+   <input type="hidden" name="@required" value="username,address"
+          tal:attributes="value python:','.join(required)">
+  </td>
+  <td><input type="submit" value="save" tal:replace="structure context/submit"><!--submit button here-->
+    <input type=reset>
   </td>
-  <td tal:content="structure context/submit">submit button here</td>
  </tr>
 </table>
 </form>
 
-<table class="form" tal:condition="context/is_only_view_ok">
- <tr>
-  <th colspan=2 class="header" tal:content="context/realname">realname</th>
- </tr>
- <tr>
-  <th>Login Name</th>
-  <td tal:content="context/username">username</td>
- </tr>
- <tr>
-  <th>Phone</th>
-  <td tal:content="context/phone">phone</td>
- </tr>
- <tr>
-  <th>Organisation</th>
-  <td tal:content="context/organisation">organisation</td>
- </tr>
- <tr>
-  <th>Timezone</th>
-  <td tal:content="context/timezone">timezone</td>
- </tr>
- <tr>
-  <th>E-mail address</th>
-  <td tal:content="context/address/email">address</td>
- </tr>
+<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="python:context.id and context.is_view_ok()"
-           tal:replace="structure context/history" />
+<tal:block tal:condition="context/id" tal:replace="structure context/history" />
+
+</div>
 
 </td>
 
-</tal:block>
+</tal:doc>
index deda319ee9365f491100275db17d9f4b6167fbae..b7b1749a647f44c293b15b5ac72532efcd2c2e58 100644 (file)
@@ -1,42 +1,36 @@
 <!-- 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:content="string:Registering with ${db/config/TRACKER_NAME}"></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"
-      tal:content="string:Registering with ${db/config/TRACKER_NAME}"></span>
+ 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">
-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"
       tal:attributes="action context/designator">
 
 <table class="form">
  <tr>
-  <th>Name</th>
+  <th i18n:translate="">Name</th>
   <td tal:content="structure context/realname/field">realname</td>
  </tr>
  <tr>
-  <th>Login Name</th>
+  <th class="required" i18n:translate="">Login Name</th>
   <td tal:content="structure context/username/field">username</td>
  </tr>
  <tr>
-  <th>Login Password</th>
+  <th class="required" i18n:translate="">Login Password</th>
   <td tal:content="structure context/password/field">password</td>
  </tr>
  <tr>
-  <th>Confirm Password</th>
+  <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>Roles</th>
+  <th i18n:translate="">Roles</th>
   <td tal:condition="exists:item"
       tal:content="structure context/roles/field">roles</td>
   <td tal:condition="not:exists:item">
@@ -44,19 +38,19 @@ You are not allowed to view this page.
   </td>
  </tr>
  <tr>
-  <th>Phone</th>
+  <th i18n:translate="">Phone</th>
   <td tal:content="structure context/phone/field">phone</td>
  </tr>
  <tr>
-  <th>Organisation</th>
+  <th i18n:translate="">Organisation</th>
   <td tal:content="structure context/organisation/field">organisation</td>
  </tr>
  <tr>
-  <th>E-mail address</th>
+  <th class="required" i18n:translate="">E-mail address</th>
   <td tal:content="structure context/address/field">address</td>
  </tr>
  <tr>
-  <th>Alternate E-mail addresses<br>One address per line</th>
+  <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>
 
@@ -66,14 +60,20 @@ You are not allowed to view this page.
    <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">
+   <input type="submit" name="submit" value="Register" i18n:attributes="value">
   </td>
  </tr>
 </table>
 </form>
 
-</tal:block>
-
+<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>
index 53ea880dc1d09f2980a98d050dc8a502eb0be594..4d6bfe4cd10802e04d9b57b003deb483f01201c5 100644 (file)
@@ -1,13 +1,15 @@
 <!-- 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">Registration in progress</title>
-<span metal:fill-slot="body_title" tal:omit-tag="python:1">
-  Registration in progress...</span>
+<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>You will shortly receive an email to confirm your registration. To
-complete the registration process, visit the link indicated in the
-email.
+<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>
diff --git a/templates/classic/html/user_utils.js b/templates/classic/html/user_utils.js
new file mode 100644 (file)
index 0000000..7b2946d
--- /dev/null
@@ -0,0 +1,114 @@
+// User Editing Utilities
+
+/**
+ * for new users:
+ * Depending on the input field which calls it, takes the value
+ * and dispatches it to certain other input fields:
+ *
+ * address
+ *  +-> username
+ *  |    `-> realname
+ *  `-> organisation
+ */
+function split_name(that) {
+    var raw = that.value
+    var val = trim(raw)
+    if (val == '') {
+        return
+    }
+    var username=''
+    var realname=''
+    var address=''
+    switch (that.name) {
+        case 'address':
+            address=val
+            break
+        case 'username':
+            username=val
+            break
+        case 'realname':
+            realname=val
+            break
+        case 'firstname':
+        case 'lastname':
+           return
+        default:
+            alert('Ooops - unknown name field '+that.name+'!')
+            return
+    }
+    var the_form = that.form;
+
+    function field_empty(name) {
+        return the_form[name].value == ''
+    }
+
+    // no break statements - on purpose!
+    switch (that.name) {
+        case 'address':
+            var split1 = address.split('@')
+            if (field_empty('username')) {
+                username = split1[0]
+                the_form.username.value = username
+            }
+            if (field_empty('organisation')) {
+                the_form.organisation.value = default_organisation(split1[1])
+            }
+        case 'username':
+            if (field_empty('realname')) {
+                realname = Cap(username.split('.').join(' '))
+                the_form.realname.value = realname
+            }
+        case 'realname':
+            if (field_empty('username')) {
+                username = Cap(realname.replace(' ', '.'))
+                the_form.username.value = username
+            }
+            if (the_form.firstname && the_form.lastname) {
+                var split2 = realname.split(' ')
+                var firstname='', lastname=''
+                firstname = split2[0]
+                lastname = split2.slice(1).join(' ')
+                if (field_empty('firstname')) {
+                    the_form.firstname.value = firstname
+                }
+                if (field_empty('lastname')) {
+                    the_form.lastname.value = lastname
+                }
+            }
+    }
+}
+
+function SubCap(str) {
+    switch (str) {
+        case 'de': case 'do': case 'da':
+        case 'du': case 'von':
+            return str;
+    }
+    if (str.toLowerCase().slice(0,2) == 'mc') {
+        return 'Mc'+str.slice(2,3).toUpperCase()+str.slice(3).toLowerCase()
+    }
+    return str.slice(0,1).toUpperCase()+str.slice(1).toLowerCase()
+}
+
+function Cap(str) {
+    var liz = str.split(' ')
+    for (var i=0; i<liz.length; i++) {
+        liz[i] = SubCap(liz[i])
+    }
+    return liz.join(' ')
+}
+
+/**
+ * Takes a domain name (behind the @ part of an email address)
+ * Customise this to handle the mail domains you're interested in 
+ */
+function default_organisation(orga) {
+    switch (orga.toLowerCase()) {
+        case 'gmx':
+        case 'yahoo':
+            return ''
+        default:
+            return orga
+    }
+}
+
diff --git a/templates/classic/initial_data.py b/templates/classic/initial_data.py
new file mode 100644 (file)
index 0000000..101768b
--- /dev/null
@@ -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
diff --git a/templates/classic/interfaces.py b/templates/classic/interfaces.py
deleted file mode 100644 (file)
index 69f4048..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-#
-# 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: interfaces.py,v 1.1 2003-04-17 03:26:03 richard Exp $
-
-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
-
-class TemplatingUtils:
-    ''' Methods implemented on this class will be available to HTML templates
-        through the 'utils' variable.
-    '''
-    pass
-
-class MailGW(mailgw.MailGW): 
-    ''' derives basic mail gateway implementation from the standard module, 
-        with any specific extensions 
-    ''' 
-    pass
-
-# vim: set filetype=python ts=4 sw=4 et si
diff --git a/templates/classic/schema.py b/templates/classic/schema.py
new file mode 100644 (file)
index 0000000..85fa495
--- /dev/null
@@ -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"),
+                keyword=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 :
diff --git a/templates/minimal/__init__.py b/templates/minimal/__init__.py
deleted file mode 100644 (file)
index fdc93c3..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-#
-# 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.1 2003-04-17 03:27:27 richard Exp $
-
-import config
-from dbinit import open, init
-from interfaces import Client, MailGW
-
-# vim: set filetype=python ts=4 sw=4 et si
diff --git a/templates/minimal/config.py b/templates/minimal/config.py
deleted file mode 100644 (file)
index 2a5799c..0000000
+++ /dev/null
@@ -1,138 +0,0 @@
-#
-# 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: config.py,v 1.6 2004-03-26 23:45:34 richard Exp $
-
-import os
-
-# roundup home is this package's directory
-TRACKER_HOME=os.path.split(__file__)[0]
-
-# The SMTP mail host that roundup will use to send mail
-MAILHOST = 'localhost'
-
-# If your SMTP mail host requires a username and password for access, then
-# specify them here.
-# eg. MAILUSER = ('username', 'password')
-MAILUSER = ()
-
-# If your SMTP mail host provides or requires TLS (Transport Layer
-# Security) then set MAILHOST_TLS = 'yes'
-# Optionallly, you may also set MAILHOST_TLS_KEYFILE to the name of a PEM
-# formatted file that contains your private key, and MAILHOST_TLS_CERTFILE
-# to the name of a PEM formatted certificate chain file.
-MAILHOST_TLS = 'no'
-MAILHOST_TLS_KEYFILE = ''
-MAILHOST_TLS_CERTFILE = ''
-
-# The domain name used for email addresses.
-MAIL_DOMAIN = 'your.tracker.email.domain.example'
-
-# This is the directory that the database is going to be stored in
-DATABASE = os.path.join(TRACKER_HOME, 'db')
-
-# This is the directory that the HTML templates reside in
-TEMPLATES = os.path.join(TRACKER_HOME, 'html')
-
-# A descriptive name for your roundup instance
-TRACKER_NAME = 'Roundup issue tracker'
-
-# The email address that mail to roundup should go to
-TRACKER_EMAIL = 'issue_tracker@%s'%MAIL_DOMAIN
-
-# 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.
-TRACKER_WEB = 'http://tracker.example/cgi-bin/roundup.cgi/bugs/'
-
-# The email address that roundup will complain to if it runs into trouble
-ADMIN_EMAIL = 'roundup-admin@%s'%MAIL_DOMAIN
-
-# The 'dispatcher' is a role that can get notified of new items to the database.
-DISPATCHER_EMAIL = ADMIN_EMAIL
-
-# 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@tracker.example>
-# the EMAIL_FROM_TAG goes inside the "Foo Bar" quotes like so:
-#    "Foo Bar EMAIL_FROM_TAG" <issue_tracker@tracker.example>
-EMAIL_FROM_TAG = ""
-
-# 
-# SECURITY DEFINITIONS
-#
-# define the Roles that a user gets when they register with the tracker
-# these are a comma-separated string of role names (e.g. 'Admin,User')
-NEW_WEB_USER_ROLES = 'User'
-NEW_EMAIL_USER_ROLES = 'User'
-
-# Send error messages to the dispatcher, user, or both?
-# If 'dispatcher', error message notifications will only be sent to the dispatcher.
-# If 'user',       error message notifications will only be sent to the user.
-# If 'both',       error message notifications will be sent to both individuals.
-ERROR_MESSAGES_TO = 'user'
-
-# Send nosy messages to the author of the message
-MESSAGES_TO_AUTHOR = 'no'           # either 'yes' or 'no'
-
-# 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_AUTHOR_TO_NOSY = 'new'          # one of 'yes', 'no', '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.
-ADD_RECIPIENTS_TO_NOSY = 'new'      # either 'yes', 'no', 'new'
-
-# Where to place the email signature
-EMAIL_SIGNATURE_POSITION = 'bottom' # one of 'top', 'bottom', 'none'
-
-# Keep email citations when accepting messages. Setting this to "no" strips
-# out "quoted" text from the message. Signatures are also stripped.
-EMAIL_KEEP_QUOTED_TEXT = 'yes'      # either 'yes' or 'no'
-
-# Preserve the email body as is - that is, keep the citations _and_
-# signatures.
-EMAIL_LEAVE_BODY_UNCHANGED = 'no'   # either 'yes' or 'no'
-
-# Default class to use in the mailgw if one isn't supplied in email
-# subjects. To disable, comment out the variable below or leave it blank.
-# Examples:
-MAIL_DEFAULT_CLASS = 'issue'   # use "issue" class by default
-#MAIL_DEFAULT_CLASS = ''        # disable (or just comment the var out)
-
-# 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.
-HTML_VERSION = 'html4'         # either 'html4' or 'xhtml'
-
-# 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'.
-EMAIL_CHARSET = 'utf-8'
-#EMAIL_CHARSET = 'iso-8859-1'   # use this instead for Eudora users
-
-# 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
-
-# vim: set filetype=python ts=4 sw=4 et si
diff --git a/templates/minimal/dbinit.py b/templates/minimal/dbinit.py
deleted file mode 100644 (file)
index 2b1a63f..0000000
+++ /dev/null
@@ -1,105 +0,0 @@
-#
-# 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: dbinit.py,v 1.2 2004-03-12 05:36:26 richard Exp $
-
-import os
-
-import config
-from select_db import Database, Class, FileClass, IssueClass
-
-def open(name=None):
-    ''' as from the roundupdb method openDB 
-    ''' 
-    from roundup.hyperdb import String, Password, Date, Link, Multilink
-    from roundup.hyperdb import Interval, Boolean, Number
-
-    # open the database
-    db = Database(config, name)
-
-    #
-    # Now initialise the schema. Must do this each time the database is
-    # opened.
-    #
-
-    # 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")
-
-    # add any additional database schema configuration here
-
-    #
-    # SECURITY SETTINGS
-    #
-    # 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)
-
-    import detectors
-    detectors.init(db)
-
-    # schema is set up - run any post-initialisation
-    db.post_init()
-    return db
-def init(adminpw): 
-    ''' as from the roundupdb method initDB 
-    Open the new database, and add new nodes - used for initialisation. You
-    can edit this before running the "roundup-admin initialise" command to
-    change the initial database entries.
-    ''' 
-    dbdir = os.path.join(config.DATABASE, 'files')
-    if not os.path.isdir(dbdir):
-        os.makedirs(dbdir)
-
-    db = open("admin")
-    db.clear()
-
-    # create the two default users
-    user = db.getclass('user')
-    user.create(username="admin", password=adminpw,
-        address=config.ADMIN_EMAIL, roles='Admin')
-    user.create(username="anonymous", roles='Anonymous')
-
-    # add any additional database create steps here - but only if you
-    # haven't initialised the database with the admin "initialise" command
-
-    db.commit()
-
-# vim: set filetype=python ts=4 sw=4 et si
-
diff --git a/templates/minimal/detectors/__init__.py b/templates/minimal/detectors/__init__.py
deleted file mode 100644 (file)
index 7f3ec6d..0000000
+++ /dev/null
@@ -1,40 +0,0 @@
-#
-# 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 2003-10-07 06:18:45 richard Exp $
-
-import sys, os, imp
-
-def init(db):
-    ''' execute the init functions of all the modules in this directory
-    '''
-    this_dir = os.path.split(__file__)[0]
-    for filename in os.listdir(this_dir):
-        name, ext = os.path.splitext(filename)
-        if name == '__init__':
-            continue
-        if ext == '.py':
-            path = os.path.abspath(os.path.join(this_dir, filename))
-            fp = open(path)
-            try:
-                module = imp.load_module(name, fp, path,
-                    ('.py', 'r', imp.PY_SOURCE))
-            finally:
-                fp.close()
-            module.init(db)
-
-# vim: set filetype=python ts=4 sw=4 et si
index b12c7e6ca0791024e5df3c7da6a2fdc6d301149c..dd7fcc4467d19dd2db2a8ab555ddb56c095e1d81 100644 (file)
 # 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 $
+#$Id: userauditor.py,v 1.8 2007-09-12 21:11:14 jpend Exp $
+
+import re
+
+# regular expression thanks to: http://www.regular-expressions.info/email.html
+# this is the "99.99% solution for syntax only".
+email_regexp = (r"[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*", r"(localhost|(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9]))")
+email_rfc = re.compile('^' + email_regexp[0] + '@' + email_regexp[1] + '$', re.IGNORECASE)
+email_local = re.compile('^' + email_regexp[0] + '$', re.IGNORECASE)
+
+def valid_address(address):
+    ''' If we see an @-symbol in the address then check against the full
+        RFC syntax. Otherwise it is a local-only address so only check
+        the local part of the RFC syntax.
+    '''
+    if '@' in address:
+        return email_rfc.match(address)
+    else:
+        return email_local.match(address)
+
+def get_addresses(user):
+    ''' iterate over all known addresses in a newvalues dict
+        this takes of the address/alterate_addresses handling
+    '''
+    if user.has_key('address'):
+        yield user['address']
+    if user.get('alternate_addresses', None):
+        for address in user['alternate_addresses'].split('\n'):
+            yield address
 
 def audit_user_fields(db, cl, nodeid, newvalues):
     ''' Make sure user properties are valid.
 
-        - email address has no spaces in it
+        - email address is syntactically valid
+        - email address is unique
         - roles specified exist
+        - timezone is valid
     '''
-    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):
+    for address in get_addresses(newvalues):
+        if not valid_address(address):
+            raise ValueError, 'Email address syntax is invalid'
+
+        check_main = db.user.stringFind(address=address)
+        # make sure none of the alts are owned by anyone other than us (x!=nodeid)
+        check_alts = [x for x in db.user.filter(None, {'alternate_addresses' : address}) if x != nodeid]
+        if check_main or check_alts:
+            raise ValueError, 'Email address %s already in use' % address
+
+    for rolename in [r.lower().strip() for r in newvalues.get('roles', '').split(',')]:
+            if rolename and not db.security.role.has_key(rolename):
                 raise ValueError, 'Role "%s" does not exist'%rolename
 
+    tz = newvalues.get('timezone', None)
+    if tz:
+        # if they set a new timezone validate the timezone by attempting to
+        # use it before we store it to the db.
+        import roundup.date
+        import datetime
+        try:
+            TZ = roundup.date.get_timezone(tz)
+            dt = datetime.datetime.now()
+            local = TZ.localize(dt).utctimetuple()
+        except IOError:
+            raise ValueError, 'Timezone "%s" does not exist' % tz
+        except ValueError:
+            raise ValueError, 'Timezone "%s" exceeds valid range [-23...23]' % tz
 
 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
+# vim: sts=4 sw=4 et si
diff --git a/templates/minimal/extensions/README.txt b/templates/minimal/extensions/README.txt
new file mode 100644 (file)
index 0000000..ff4dff3
--- /dev/null
@@ -0,0 +1,6 @@
+This directory is for tracker extensions:
+
+- CGI Actions
+- Templating functions
+
+See the customisation doc for more information.
diff --git a/templates/minimal/html/_generic.404.html b/templates/minimal/html/_generic.404.html
new file mode 100644 (file)
index 0000000..71c9e0e
--- /dev/null
@@ -0,0 +1,9 @@
+<html>
+<head>
+<title>Item Not Found</title>
+</head>
+
+<body>
+There is no <span tal:content="context/_classname" /> with id <span tal:content="context/id"/>
+</body>
+</html>
diff --git a/templates/minimal/html/_generic.calendar.html b/templates/minimal/html/_generic.calendar.html
new file mode 100644 (file)
index 0000000..dbc3b57
--- /dev/null
@@ -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>
index fd2cc2b23d6a710cc22ad8b61dc7c5f3d2664061..2a2768a3d7b576fe73902b0150d4654ce61d9adf 100644 (file)
@@ -1,11 +1,16 @@
 <tal:block metal:use-macro="templates/page/macros/icing">
-  <title metal:fill-slot="head_title"
-         tal:content="python:context._classname.capitalize()+' Edit Collision'"></title>
-  <span metal:fill-slot="body_title" tal:omit-tag="python:1"
-        tal:content="python:context._classname.capitalize()+' Edit Collision'"></span>
-  <td class="content" metal:fill-slot="content">
-    There has been a collision. Another user updated this node while you were
-    editing. Please <a tal:attributes="href context/designator">reload</a>
-    the node and review your edits.
-  </td>
-</tal:block>
\ No newline at end of file
+<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>
index 9eee58fa3c50d2220652e6d7018f4de00981e20f..069c0a6cb8eeca5d5798550001cec3af0a170ed2 100644 (file)
@@ -1,77 +1,98 @@
 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
-<html>
+<html tal:define="property request/form/property/value" >
   <head>
-      <link rel="stylesheet" type="text/css" href="_file/style.css" />
-      <meta http-equiv="Content-Type" content="text/html; charset=utf-8;" />
+      <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 tal:content="string:${request/form/property/value} help">Property</title>
-      <script language="Javascript" type="text/javascript" 
+      <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
-          field = '${request/form/property/value}';" >
+          form  = window.opener.document.${request/form/form/value};
+          field  = '${request/form/property/value}';">
       </script>
-      <script src="_file/help_controls.js" type="text/javascript"><!-- 
+      <script src="@@file/help_controls.js" type="text/javascript"><!--
       //--></script>
       </tal:block>
   </head>
- <body class="body" marginwidth="0" marginheight="0" onload="resetList();">
- <form name="frm_help" action=""
-       tal:define="start python:int(request.form['@startwith'].value);
-                   batch python:utils.Batch(context.list(), 500, start);
+ <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" tal:condition="python:start==0">
-       <!--input type="button" name="btn_clear" 
+
+     <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();"/>
+       <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();"/>     
+              value=" Apply " onclick="updateList(); window.close();"
+              i18n:attributes="value" />
      </div>
-
-     <table class="classhelp">
-       <tr>
-           <th>&nbsp;<b>x</b></th>
-           <th tal:repeat="prop props" tal:content="prop"></th>
-       </tr>
-       <tr tal:repeat="item batch">
-           <td>
-               <input type="checkbox" name="check" 
-               onclick="updatePreview();"
-               tal:condition="python:start==0"
-               tal:define="attr python:item[props[0]]"
-               tal:attributes="value attr; id attr" />
-           </td>
-           <td tal:repeat="prop props">
-               <label class="classhelp-label"
-                      tal:attributes="for python:item[props[0]]" 
-                      tal:content="structure python:item[prop]"></label>
-           </td>
-       </tr>
-       <tr>
-           <th>&nbsp;<b>x</b></th>
-           <th tal:repeat="prop props" tal:content="prop"></th>
-       </tr>
-     </table>
      <table width="100%">
       <tr class="navigation">
        <th>
         <a tal:define="prev batch/previous" tal:condition="prev"
-           tal:attributes="href string:${request/classname}?@template=help&@startwith=${prev/first}&properties=${request/form/properties/value}">&lt;&lt; previous</a>
+           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 tal:content="python: '%d...%d out of %d'%(batch.start,
-               batch.start+batch.length-1, batch.sequence_length)">current</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 string:${request/classname}?@template=help&@startwith=${next/first}&properties=${request/form/properties/value}">next &gt;&gt;</a>
+           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="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>
index e2ddefa86d1f2384c3715314ae3d05edad3d96a1..81918ef8401c870c58c65724171d022fb3165f96 100644 (file)
@@ -1,20 +1,31 @@
 <!-- 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"
-       tal:content="python:context._classname.capitalize()+' editing'"></title>
-<span metal:fill-slot="body_title" tal:omit-tag="python:1"
-      tal:content="python:context._classname.capitalize()+' editing'"></span>
+<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())">
-You are not allowed to view this page.
-</span>
+<span tal:condition="python:not (context.is_view_ok() or context.is_edit_ok()
+ or request.user.hasRole('Anonymous'))"
+ tal:omit-tag="python:1" i18n:translate=""
+>You are not allowed to view this page.</span>
+
+<span tal:condition="python:not (context.is_view_ok() or context.is_edit_ok())
+ and request.user.hasRole('Anonymous')"
+ tal:omit-tag="python:1" i18n:translate=""
+>Please login with your username and password.</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" />
+ 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
@@ -22,7 +33,7 @@ You are not allowed to view this page.
 </p>
 
 <p class="form-help">
- Multilink properties have their multiple values colon (":") separated 
+ Multilink properties have their multiple values colon (":") separated
  (... ,"one:two:three", ...)
 </p>
 
@@ -30,18 +41,29 @@ You are not allowed to view this page.
  Remove entries by deleting their line. Add new entries by appending
  them to the table - put an X in the id column.
 </p>
-
-<form onSubmit="return submit_once()" method="POST">
-<textarea rows="15" cols="60" name="rows" tal:content="context/csv"></textarea>
+</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">
+<input type="submit" value="Edit Items" i18n:attributes="value">
 </form>
 </tal:block>
 
-<tal:block tal:condition="context/is_only_view_ok">
-view ok
-</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>
 
index 8ca8796d0a6cad235392a910d8188e617da4a454..ac60acb2b3d9c415ed6ca607fcf3e96ac79cc7a7 100644 (file)
@@ -1,24 +1,35 @@
 <tal:block metal:use-macro="templates/page/macros/icing">
-<title metal:fill-slot="head_title"
-       tal:content="python:context._classname.capitalize()+' editing'"></title>
-<span metal:fill-slot="body_title" tal:omit-tag="python:1"
-      tal:content="python:context._classname.capitalize()+' editing'"></span>
+<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())">
-You are not allowed to view this page.
-</span>
+<span tal:condition="python:not (context.is_view_ok() or context.is_edit_ok()
+ or request.user.hasRole('Anonymous'))"
+ tal:omit-tag="python:1" i18n:translate=""
+>You are not allowed to view this page.</span>
+
+<span tal:condition="python:not (context.is_view_ok() or context.is_edit_ok())
+ and request.user.hasRole('Anonymous')"
+ tal:omit-tag="python:1" i18n:translate=""
+>Please login with your username and password.</span>
 
 <form method="POST" onSubmit="return submit_once()"
-      enctype="multipart/form-data" tal:condition="context/is_edit_ok">
+      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', 'activity')">
+ <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>
diff --git a/templates/minimal/html/help_controls.js b/templates/minimal/html/help_controls.js
new file mode 100644 (file)
index 0000000..d3b5529
--- /dev/null
@@ -0,0 +1,111 @@
+// initial values for either Nosy, Superseder, Keyword 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();}
+    }
+}
+
index 637f353e7b0f03950c33fbae2666fdfa1c5f131e..930306bc3d32b5629b0c2ef44cbdd014e39bb9a8 100644 (file)
@@ -1,6 +1,8 @@
 <tal:block metal:use-macro="templates/page/macros/icing">
-<title metal:fill-slot="head_title">List of classes</title>
-<span metal:fill-slot="body_title" tal:omit-tag="python:1">List of classes</span>
+<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">
 
index 9a28ee6880b67f2d482e1e34800856421f43857d..c933c591b8ff74a70424e6cf31043351700e1725 100644 (file)
@@ -1,6 +1,8 @@
 <tal:block metal:use-macro="templates/page/macros/icing">
-<title metal:fill-slot="head_title">Tracker home</title>
-<span metal:fill-slot="body_title" tal:omit-tag="python:1">Tracker home</span>
+<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">
 
 <!--
 -->
 
 <tal:block tal:define="anon python:request.user.username == 'anonymous'">
-<p tal:condition="not:anon" class="help">
-Please select from one of the menu options on the right.
+<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">
+<p tal:condition="anon" class="help" i18n:translate="">
 Please log in or register.
 </p>
 </tal:block>
index 6afd46f6a3e4e2644e14dcdd227384880c820fbc..dbbbb5e576be571a06912d55cae65e9b2fbe96d0 100644 (file)
-<tal:block metal:define-macro="icing">
-<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
-                               "http://www.w3.org/TR/html4/strict.dtd">
+<!-- vim:sw=2 sts=2
+--><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>
-<meta http-equiv="Content-Type" content="text/html; charset=utf-8;">
-
 <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>
+<metal:x define-slot="more-javascript" />
 
 </head>
-<body class="body" marginwidth="0" marginheight="0">
+<body class="body">
 
-<table class="body">
+<table class="body"
+ tal:define="
+kw_edit python:request.user.hasPermission('Edit', 'keyword');
+kw_create python:request.user.hasPermission('Create', 'keyword');
+kw_edit_link python:kw_edit and db.keyword.list();
+columns string:id,activity,title,creator,status;
+columns_showall string:id,activity,title,creator,assignedto,status;
+status_notresolved string:-1,1,2,3,4,5,6,7;
+"
+>
 
 <tr>
  <td class="page-header-left">&nbsp;</td>
- <td class="page-header-top"><h2><span metal:define-slot="body_title">body title</span></h2></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"
+             tal:attributes="value columns_showall"
+             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"
+              tal:attributes="value request/search_text"/>
+       <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="">
+  <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 href="#" 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="#"
+       tal:attributes="href python:request.indexargs_url('issue', {
+      '@sort': '-activity',
+      '@group': 'priority',
+      '@filter': 'status,assignedto',
+      '@columns': columns,
+      '@search_text': '',
+      'status': status_notresolved,
+      'assignedto': '-1',
+      '@dispname': i18n.gettext('Show Unassigned'),
+     })"
+       i18n:translate="">Show Unassigned</a><br>
+    <a href="#"
+       tal:attributes="href python:request.indexargs_url('issue', {
+      '@sort': '-activity',
+      '@group': 'priority',
+      '@filter': 'status',
+      '@columns': columns_showall,
+      '@search_text': '',
+      'status': status_notresolved,
+      '@dispname': i18n.gettext('Show All'),
+     })"
+       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:kw_edit or kw_create">
+   <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="kw_edit_link">
+    <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="submit" name="@action" value="login">
+    <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" />
-   </form>
-   <a tal:condition="python:request.user.hasPermission('Web Registration')"
-      href="user?@template=register">Register</a>
-  </p>
+    <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>Hello,</b><br><b tal:content="request/user/username">username</b><br>
-   <a tal:attributes="href string:user${request/user/id}">My Details</a><br>
-   <a tal:attributes="href python:request.indexargs_href('',
-       {'@action':'logout'})">Logout</a>
+   <b i18n:translate="">Hello, <span i18n:name="user"
+    tal:replace="python:request.user.username.plain(escape=1)">username</span></b><br>
+   <a href="#" tal:attributes="href string:user${request/user/id}"
+    i18n:translate="">Your Details</a><br>
+   <a href="#" 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>Administration</b><br>
-   <a tal:condition="python:request.user.hasPermission('Edit', None)"
-      href="home?@template=classlist">Class List</a><br>
-   <a tal:condition="python:request.user.hasPermission('View', 'user')
-                            or request.user.hasPermission('Edit', 'user')"
-      href="user" >User List</a><br>
-   <a tal:condition="python:request.user.hasPermission('Edit', 'user')"
-      href="user?@template=item">Add User</a>
+  <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">error</p>
-  <p tal:condition="options/ok_message | nothing" class="ok-message"
-     tal:repeat="m options/ok_message" tal:content="structure m">error</p>
+     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>
 </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.
+-->
+
+<!-- creates a th and a label: -->
+<th metal:define-macro="th_label"
+    tal:define="required required | python:[]"
+    tal:attributes="class python:(name in required) and 'required' or nothing">
+  <label tal:attributes="for name" tal:content="label" i18n:translate="">text</label>
+       <metal:x define-slot="behind_the_label" />
+</th>
+
+<td metal:define-macro="search_input">
+  <input tal:attributes="value python:request.form.getvalue(name) or nothing;
+                         name name;
+                         id name">
+</td>
+
+<td metal:define-macro="search_date">
+  <input tal:attributes="value python:request.form.getvalue(name) or nothing;
+                         name name;
+                         id name">
+  <a class="classhelp"
+        tal:attributes="href python:'''javascript:help_window('issue?@template=calendar&property=%s&form=itemSynopsis', 300, 200)'''%name">(cal)</a>
+</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;
+                         id name">
+  <span tal:replace="structure python:db.issue.classhelp(columns,
+                                      property=name)" />
+</td>
+
+<td metal:define-macro="search_select">
+  <select tal:attributes="name name; id name"
+          tal:define="value python:request.form.getvalue(name)">
+    <option value="" i18n:translate="">don't care</option>
+    <metal:slot define-slot="extra_options" />
+    <option value="" i18n:translate="" disabled="disabled">------------</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>
+
+<!-- like search_select, but translates the further values.
+Could extend it (METAL 1.1 attribute "extend-macro")
+-->
+<td metal:define-macro="search_select_translated">
+  <select tal:attributes="name name; id name"
+          tal:define="value python:request.form.getvalue(name)">
+    <option value="" i18n:translate="">don't care</option>
+    <metal:slot define-slot="extra_options" />
+    <option value="" i18n:translate="" disabled="disabled">------------</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]"
+                                               i18n:translate=""></option>
+  </select>
+</td>
+
+<!-- currently, there is no convenient API to get a list of all roles -->
+<td metal:define-macro="search_select_roles"
+         tal:define="onchange onchange | nothing">
+  <select name=roles id=roles tal:attributes="onchange onchange">
+    <option value="" i18n:translate="">don't care</option>
+    <option value="" i18n:translate="" disabled="disabled">------------</option>
+    <option value="User">User</option>
+    <option value="Admin">Admin</option>
+    <option value="Anonymous">Anonymous</option>
+  </select>
+</td>
+
+<td metal:define-macro="search_multiselect">
+  <input tal:attributes="value python:request.form.getvalue(name) or nothing;
+                         name name;
+                         id 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>
+
+<!--
+The following macros are intended for user editing.
+
+The invoking context must define a "name" variable which names the
+property being searched; the "edit_ok" variable tells whether the
+current user is allowed to edit.
+
+See user.item.html in the classic template for examples.
+-->
+<script metal:define-macro="user_utils" type="text/javascript" src="@@file/user_utils.js"></script>
+
+<!-- src: value will be re-used for other input fields -->
+<input metal:define-macro="user_src_input"
+    type="text" tal:attributes="onblur python:edit_ok and 'split_name(this)';
+    id name; name name; value value; readonly not:edit_ok"
+    value="heinz.kunz">
+<!-- normal: no re-using -->
+<input metal:define-macro="user_normal_input" type="text"
+    tal:attributes="id name; name name; value value; readonly not:edit_ok"
+    value="heinz">
+<!-- password: type; no initial value -->
+    <input metal:define-macro="user_pw_input" type="password"
+    tal:attributes="id name; name name; readonly not:edit_ok" value="">
+    <input metal:define-macro="user_confirm_input" type="password"
+    tal:attributes="id name; name string:@confirm@$name; readonly not:edit_ok" value="">
+
index 7560e6a185e662c0341d3c42fca370aad4b13e8a..f7d7eb411d041947130612fdf508a88c9014de71 100644 (file)
@@ -6,12 +6,10 @@ body.body {
   margin: 0;
 }
 a[href]:hover {
-  background-color: white;
   color:blue;
   text-decoration: underline;
 }
 a[href], a[href]:link {
-  background-color: white;
   color:blue;
   text-decoration: none;
 }
@@ -40,12 +38,35 @@ td.sidebar {
     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;
@@ -69,6 +90,12 @@ td.sidebar p.userblock {
   background-color: #eef;
 }
 
+.form-small {
+  padding: 0;
+  font-size: 75%;
+}
+
+
 td.content {
   padding: 1px 5px 1px 5px;
   vertical-align: top;
@@ -91,6 +118,22 @@ p.error-message {
   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 */
index 100c4a92ae4f8a5c52f0ac66a7717b34c6a040d9..f0a84b86e3595aa6c61479bfda40d3dcb14159aa 100644 (file)
@@ -1,26 +1,34 @@
 <!-- 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">User listing</title>
-<span metal:fill-slot="body_title" tal:omit-tag="python:1">User listing</span>
+<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">
-You are not allowed to view this page.
-</span>
+<span tal:condition="python:not (context.is_view_ok()
+ or request.user.hasRole('Anonymous'))"
+ i18n:translate="">You are not allowed to view this page.</span>
+
+<span tal:condition="python:not context.is_view_ok()
+ and request.user.hasRole('Anonymous')"
+ i18n:translate="">Please login with your username and password.</span>
 
 <table width="100%" tal:condition="context/is_view_ok" class="list">
 <tr>
- <th>Username</th>
- <th>Email address</th>
+ <th i18n:translate="">Username</th>
+ <th i18n:translate="">Email address</th>
 </tr>
-<tr tal:repeat="user context/list"
+<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>
+ </tr>
+</tal:block>
 </table>
 </td>
 
index 2592471d58a045707525819adf4ae71c44c6d54c..bafccd260b8f7196cc95e00abecbee91bde30a4c 100644 (file)
 <!-- 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">User editing</title>
-<span metal:fill-slot="body_title" tal:omit-tag="python:1">
-  User<span tal:replace="context/id" />
-   <tal:x tal:condition="context/is_edit_ok">Editing</tal:x>
-</span>
+<tal:doc metal:use-macro="templates/page/macros/icing"
+define="edit_ok context/is_edit_ok"
+>
+<title metal:fill-slot="head_title">
+<tal:if condition="context/id" i18n:translate=""
+ >User <tal:x content="context/id" i18n:name="id"
+ />: <tal:x content="context/username" i18n:name="title"
+ /> - <tal:x content="config/TRACKER_NAME" i18n:name="tracker"
+/></tal:if>
+<tal:if condition="not:context/id" i18n:translate=""
+ >New User - <span tal:replace="config/TRACKER_NAME" i18n:name="tracker"
+/></tal:if>
+</title>
+<metal:slot fill-slot="more-javascript">
+<script metal:use-macro="templates/page/macros/user_utils"></script>
+<script type="text/javascript" src="@@file/help_controls.js"></script>
+</metal:slot>
+<tal:block metal:fill-slot="body_title"
+  define="edit_ok context/is_edit_ok">
+ <span tal:condition="python: not (context.id or edit_ok)"
+  tal:omit-tag="python:1" i18n:translate="">New User</span>
+ <span tal:condition="python: not context.id and edit_ok"
+  tal:omit-tag="python:1" i18n:translate="">New User Editing</span>
+ <span tal:condition="python: context.id and not 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 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">
-<span tal:condition="python:not (context.is_view_ok() or context.is_edit_ok())">
-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">
+<p tal:condition="python:not (context.is_view_ok()
+ or request.user.hasRole('Anonymous'))" i18n:translate="">
+ You are not allowed to view this page.</p>
 
+<p tal:condition="python:not context.is_view_ok()
+ and request.user.hasRole('Anonymous')" i18n:translate="">
+ Please login with your username and password.</p>
 
-<table class="form">
- <tr>
-  <th>Login Name</th>
-  <td tal:content="structure context/username/field">username</td>
+<div tal:condition="context/is_view_ok">
+
+<form method="POST"
+      name="itemSynopsis"
+      tal:define="required python:'username address'.split()"
+      enctype="multipart/form-data"
+      tal:attributes="action context/designator;
+      onSubmit python:'return checkRequiredFields(\'%s\')'%'\', \''.join(required);
+      ">
+<table class="form" tal:define="
+  th_label templates/page/macros/th_label;
+  src_input templates/page/macros/user_src_input;
+  normal_input templates/page/macros/user_normal_input;
+  pw_input templates/page/macros/user_pw_input;
+  confirm_input templates/page/macros/user_confirm_input;
+  edit_ok context/is_edit_ok;
+  ">
+ <tr tal:define="name string:realname; label string:Name; value context/realname; edit_ok edit_ok">
+  <th metal:use-macro="th_label">Name</th>
+  <td><input name="realname" metal:use-macro="src_input"></td>
  </tr>
- <tr>
-  <th>Login Password</th>
-  <td tal:content="structure context/password/field">password</td>
+ <tr tal:define="name string:username; label string:Login Name; value context/username">
+   <th metal:use-macro="th_label">Login Name</th>
+   <td><input metal:use-macro="src_input"></td>
  </tr>
- <tr>
-  <th>Confirm Password</th>
-  <td tal:content="structure context/password/confirm">password</td>
+ <tal:if condition="edit_ok">
+ <tr tal:define="name string:password; label string:Login Password">
+  <th metal:use-macro="th_label">Login Password</th>
+  <td><input metal:use-macro="pw_input" type="password"></td>
+ </tr>
+ <tr tal:define="name string:password; label string:Confirm Password">
+  <th metal:use-macro="th_label">Confirm Password</th>
+  <td><input metal:use-macro="confirm_input" type="password"></td>
  </tr>
- <tr tal:condition="python:request.user.hasPermission('Web Roles')">
-  <th>Roles</th>
-  <td tal:condition="context/id"
-      tal:content="structure context/roles/field">roles</td>
-  <td tal:condition="not:context/id">
-   <input name="roles" tal:attributes="value db/config/NEW_WEB_USER_ROLES">
+ </tal:if>
+ <tal:if condition="python:request.user.hasPermission('Web Roles')">
+ <tr tal:define="name string:roles; label string:Roles;">
+  <th><label for="roles" i18n:translate="">Roles</label></th>
+  <td tal:define="gips context/id">
+    <tal:subif condition=gips define="value context/roles">
+      <input metal:use-macro="normal_input">
+    </tal:subif>
+    <tal:subif condition="not:gips" define="value db/config/NEW_WEB_USER_ROLES">
+      <input metal:use-macro="normal_input">
+    </tal:subif>
+   <tal:block i18n:translate="">(to give the user more than one role,
+    enter a comma,separated,list)</tal:block>
   </td>
  </tr>
- <tr>
-  <th>E-mail address</th>
-  <td tal:content="structure context/address/field">address</td>
+ </tal:if>
+
+ <tr tal:define="name string:phone; label string:Phone; value context/phone">
+  <th metal:use-macro="th_label">Phone</th>
+  <td><input name="phone" metal:use-macro="normal_input"></td>
  </tr>
- <tr>
-  <th>Alternate E-mail addresses<br>One address per line</th>
-  <td tal:content="structure context/alternate_addresses/multiline">alternate_addresses</td>
+
+ <tr tal:define="name string:organisation; label string:Organisation; value context/organisation">
+  <th metal:use-macro="th_label">Organisation</th>
+  <td><input name="organisation" metal:use-macro="normal_input"></td>
+ </tr>
+
+ <tr tal:condition="python:edit_ok or context.timezone"
+     tal:define="name string:timezone; label string:Timezone; value context/timezone">
+  <th metal:use-macro="th_label">Timezone</th>
+  <td><input name="timezone" metal:use-macro="normal_input">
+   <tal:block tal:condition="edit_ok" 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 tal:define="name string:address; label string:E-mail address; value context/address">
+  <th metal:use-macro="th_label">E-mail address</th>
+  <td tal:define="mailto python:context.address.field(id='address');
+         mklink python:mailto and not edit_ok">
+      <a href="mailto:calvin@the-z.org"
+                 tal:attributes="href string:mailto:$value"
+                 tal:content="value"
+          tal:condition="python:mklink">calvin@the-z.org</a>
+      <tal:if condition=edit_ok>
+      <input metal:use-macro="src_input" value="calvin@the-z.org">
+      </tal:if>
+      &nbsp;
+  </td>
  </tr>
 
  <tr>
-  <td>&nbsp;
-   <input type="hidden" name="@required" value="username,address">
+  <th><label for="alternate_addresses" i18n:translate="">Alternate E-mail addresses<br>One address per line</label></th>
+  <td>
+    <textarea rows=5 cols=40 tal:replace="structure context/alternate_addresses/multiline">nobody@nowhere.org
+anybody@everywhere.net
+(alternate_addresses)
+    </textarea>
+  </td>
+ </tr>
+
+ <tr tal:condition="edit_ok">
+  <td>
+   &nbsp;
    <input type="hidden" name="@template" value="item">
+   <input type="hidden" name="@required" value="username,address"
+          tal:attributes="value python:','.join(required)">
+  </td>
+  <td><input type="submit" value="save" tal:replace="structure context/submit"><!--submit button here-->
+    <input type=reset>
   </td>
-  <td tal:content="structure context/submit">submit button here</td>
  </tr>
 </table>
 </form>
 
-<table class="form" tal:condition="context/is_only_view_ok">
- <tr>
-  <th>Login Name</th>
-  <td tal:content="context/username">username</td>
- </tr>
- <tr>
-  <th>E-mail address</th>
-  <td tal:content="context/address/email">address</td>
- </tr>
+<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" />
 
-<tal:block tal:condition="python:context.id and context.is_view_ok()"
-           tal:replace="structure context/history" />
+</div>
 
 </td>
 
-</tal:block>
+</tal:doc>
index 7e66ccc476c83505aa0eac8321441a55da7d02f8..7e7a5d3aefcbaa8e210bd6316c83f90d9cb7c014 100644 (file)
@@ -1,17 +1,21 @@
 <!-- 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:content="string:Registering with ${db/config/TRACKER_NAME}"></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"
-      tal:content="string:Registering with ${db/config/TRACKER_NAME}"></span>
+ 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">
-You are not allowed to view this page.
-</span>
+<span tal:condition="python:not (editok or request.user.hasRole('Anonymous'))"
+ i18n:translate="">You are not allowed to view this page.</span>
+
+<span tal:condition="python:not editok and request.user.hasRole('Anonymous')"
+ i18n:translate="">Please login with your username and password.</span>
 
 <tal:block tal:condition="editok">
 <form method="POST" onSubmit="return submit_once()" enctype="multipart/form-data">
@@ -22,23 +26,19 @@ You are not allowed to view this page.
 
 <table class="form">
  <tr>
-  <th>Name</th>
-  <td tal:content="structure context/realname/field">realname</td>
- </tr>
- <tr>
-  <th>Login Name</th>
+  <th i18n:translate="">Login Name</th>
   <td tal:content="structure context/username/field">username</td>
  </tr>
  <tr>
-  <th>Login Password</th>
+  <th i18n:translate="">Login Password</th>
   <td tal:content="structure context/password/field">password</td>
  </tr>
  <tr>
-  <th>Confirm Password</th>
+  <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>Roles</th>
+  <th i18n:translate="">Roles</th>
   <td tal:condition="exists:item"
       tal:content="structure context/roles/field">roles</td>
   <td tal:condition="not:exists:item">
@@ -46,19 +46,11 @@ You are not allowed to view this page.
   </td>
  </tr>
  <tr>
-  <th>Phone</th>
-  <td tal:content="structure context/phone/field">phone</td>
- </tr>
- <tr>
-  <th>Organisation</th>
-  <td tal:content="structure context/organisation/field">organisation</td>
- </tr>
- <tr>
-  <th>E-mail address</th>
+  <th i18n:translate="">E-mail address</th>
   <td tal:content="structure context/address/field">address</td>
  </tr>
  <tr>
-  <th>Alternate E-mail addresses<br>One address per line</th>
+  <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>
 
@@ -66,7 +58,7 @@ You are not allowed to view this page.
   <td>&nbsp;</td>
   <td>
    <input type="hidden" name=":action" value="register">
-   <input type="submit" name="submit" value="Register">
+   <input type="submit" name="submit" value="Register" i18n:attributes="value">
   </td>
  </tr>
 </table>
diff --git a/templates/minimal/html/user.rego_progress.html b/templates/minimal/html/user.rego_progress.html
new file mode 100644 (file)
index 0000000..4d6bfe4
--- /dev/null
@@ -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>
diff --git a/templates/minimal/initial_data.py b/templates/minimal/initial_data.py
new file mode 100644 (file)
index 0000000..28bfefe
--- /dev/null
@@ -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 :
diff --git a/templates/minimal/interfaces.py b/templates/minimal/interfaces.py
deleted file mode 100644 (file)
index e74ce46..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-#
-# 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: interfaces.py,v 1.1 2003-04-17 03:27:27 richard Exp $
-
-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
-
-class TemplatingUtils:
-    ''' Methods implemented on this class will be available to HTML templates
-        through the 'utils' variable.
-    '''
-    pass
-
-class MailGW(mailgw.MailGW): 
-    ''' derives basic mail gateway implementation from the standard module, 
-        with any specific extensions 
-    ''' 
-    pass
-
-# vim: set filetype=python ts=4 sw=4 et si
diff --git a/templates/minimal/schema.py b/templates/minimal/schema.py
new file mode 100644 (file)
index 0000000..909c9b1
--- /dev/null
@@ -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 :
index 026d3651496e42c1788f4a8d21b653a8272ae98d..9d2bb37f20b0f5f1ae49e2bf19604644c0ae6829 100644 (file)
@@ -1,4 +1,4 @@
-$Id: README.txt,v 1.2 2001-07-29 07:01:39 richard Exp $
+$Id: README.txt,v 1.3 2004-10-24 08:37:58 a1s Exp $
 
 Structure of the tests:
 
@@ -8,7 +8,6 @@ Structure of the tests:
    2   Set up schema
    3   Open with specific backend
    3.1 anydbm
-   3.2 bsddb
    4   Create database base set (stati, priority, etc)
    5   Perform some actions
    6   Perform mail import
@@ -17,16 +16,3 @@ Structure of the tests:
    6.3 text/html
    6.4 multipart/alternative (with one text/plain)
    6.5 multipart/alternative (with no text/plain)
-
-
-------
-$Log: not supported by cvs2svn $
-Revision 1.1  2001/07/27 07:16:21  richard
-rename for consistency
-
-Revision 1.1  2001/07/27 06:55:07  richard
-moving tests -> test
-
-Revision 1.2  2001/07/25 04:34:31  richard
-Added id and log to tests files...
-
index bb51f453498aa7e6f847d64ce37e9d429db6ff67..b42a7a2ae4c0a2d068407624fb493ebcee8e0137 100644 (file)
@@ -1,9 +1,10 @@
-import sys, os, time, shutil
+import sys, os, time
 
 from roundup.hyperdb import String, Password, Link, Multilink, Date, \
     Interval, DatabaseError, Boolean, Number
 from roundup import date, password
-from roundup.indexer import Indexer
+
+from db_test_base import config
 
 def setupSchema(db, module):
     status = module.Class(db, "status", name=String())
@@ -21,21 +22,6 @@ def setupSchema(db, module):
     db.post_init()
     db.commit()
 
-class config:
-    DATABASE='_benchmark'
-    GADFLY_DATABASE = ('test', DATABASE)
-    MAILHOST = 'localhost'
-    MAIL_DOMAIN = 'fill.me.in.'
-    TRACKER_NAME = 'Roundup issue tracker'
-    TRACKER_EMAIL = 'issue_tracker@%s'%MAIL_DOMAIN
-    TRACKER_WEB = 'http://some.useful.url/'
-    ADMIN_EMAIL = 'roundup-admin@%s'%MAIL_DOMAIN
-    FILTER_POSITION = 'bottom'      # one of 'top', 'bottom', 'top and bottom'
-    ANONYMOUS_ACCESS = 'deny'       # either 'deny' or 'allow'
-    ANONYMOUS_REGISTER = 'deny'     # either 'deny' or 'allow'
-    MESSAGES_TO_AUTHOR = 'no'       # either 'yes' or 'no'
-    EMAIL_SIGNATURE_POSITION = 'bottom'
-
 def main(backendname, time=time.time, numissues=10):
     try:
         exec('from roundup.backends import %s as backend'%backendname)
@@ -135,13 +121,14 @@ 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 bsddb bsddb3 metakit sqlite'.split():
+    for name in 'anydbm metakit sqlite'.split():
         main(name)
-    for name in 'anydbm bsddb bsddb3 metakit sqlite'.split():
+    for name in 'anydbm metakit sqlite'.split():
         main(name, numissues=20)
-    for name in 'anydbm bsddb bsddb3 metakit sqlite'.split():
+    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 :
index 57ad7c95bf60eeecd3c049fd47330a5a68c2676d..20bdafbab6dd08c3a759903ff59b90b3f5191ce8 100644 (file)
 # 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.24 2004-04-08 00:43:23 richard Exp $ 
+#
+# $Id: db_test_base.py,v 1.101 2008-08-19 01:40:59 richard Exp $
 
-import unittest, os, shutil, errno, imp, sys, time, pprint
+import unittest, os, shutil, errno, imp, sys, time, pprint, sets, base64, os.path
 
 from roundup.hyperdb import String, Password, Link, Multilink, Date, \
     Interval, DatabaseError, Boolean, Number, Node
-from roundup import date, password
-from roundup import init
+from roundup.mailer import Mailer
+from roundup import date, password, init, instance, configuration, support
+
+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, os.path.join(os.path.dirname(__file__), '..',
+        '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()
+        init.write_select_db(dirname, backend)
+    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())
+        assignable=Boolean(), age=Number(), roles=String(), address=String(),
+        supervisor=Link('user'),realname=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'))
+        foo=Interval(), files=Multilink("file"), assignedto=Link('user'),
+        priority=Link('priority'), spam=Multilink('msg'),
+        feedback=Link('msg'))
     stuff = module.Class(db, "stuff", stuff=String())
     session = module.Class(db, 'session', title=String())
+    msg = module.FileClass(db, "msg", date=Date(),
+                           author=Link("user", do_journal='no'),
+                           files=Multilink('file'), inreplyto=String(),
+                           messageid=String(),
+                           recipients=Multilink("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'))
+            password=password.Password('sekrit'), address='fred@example.com')
         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):
@@ -57,20 +114,11 @@ class MyTestCase(unittest.TestCase):
         if os.path.exists(config.DATABASE):
             shutil.rmtree(config.DATABASE)
 
-class config:
-    DATABASE='_test_dir'
-    MAILHOST = 'localhost'
-    MAIL_DOMAIN = 'fill.me.in.'
-    TRACKER_NAME = 'Roundup issue tracker'
-    TRACKER_EMAIL = 'issue_tracker@%s'%MAIL_DOMAIN
-    TRACKER_WEB = 'http://some.useful.url/'
-    ADMIN_EMAIL = 'roundup-admin@%s'%MAIL_DOMAIN
-    FILTER_POSITION = 'bottom'      # one of 'top', 'bottom', 'top and bottom'
-    ANONYMOUS_ACCESS = 'deny'       # either 'deny' or 'allow'
-    ANONYMOUS_REGISTER = 'deny'     # either 'deny' or 'allow'
-    MESSAGES_TO_AUTHOR = 'no'       # either 'yes' or 'no'
-    EMAIL_SIGNATURE_POSITION = 'bottom'
-
+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):
@@ -162,6 +210,18 @@ class DBTest(MyTestCase):
             if commit: self.db.commit()
             self.assertEqual(self.db.file.get(nid, 'content'), 'eggs')
 
+    def testStringUnicode(self):
+        # test set & retrieve
+        ustr = u'\xe4\xf6\xfc\u20ac'.encode('utf8')
+        nid = self.db.issue.create(title=ustr, status='1')
+        self.assertEqual(self.db.issue.get(nid, 'title'), ustr)
+
+        # change and make sure we retrieve the correct value
+        ustr2 = u'change \u20ac change'.encode('utf8')
+        self.db.issue.set(nid, title=ustr2)
+        self.db.commit()
+        self.assertEqual(self.db.issue.get(nid, 'title'), ustr2)
+
     # Link
     def testLinkChange(self):
         self.assertRaises(IndexError, self.db.issue.create, title="spam",
@@ -201,9 +261,55 @@ class DBTest(MyTestCase):
             m = self.db.issue.get(nid, "nosy"); m.sort()
             self.assertEqual(l, m)
 
+            # verify that when we pass None to an Multilink it sets
+            # it to an empty list
+            self.db.issue.set(nid, nosy=None)
+            if commit: self.db.commit()
+            self.assertEqual(self.db.issue.get(nid, "nosy"), [])
+
+    def testMultilinkChangeIterable(self):
+        for commit in (0,1):
+            # invalid nosy value assertion
+            self.assertRaises(IndexError, self.db.issue.create, title='spam',
+                nosy=['foo%s'%commit])
+            # invalid type for nosy create
+            self.assertRaises(TypeError, self.db.issue.create, title='spam',
+                nosy=1)
+            u1 = self.db.user.create(username='foo%s'%commit)
+            u2 = self.db.user.create(username='bar%s'%commit)
+            # try a couple of the built-in iterable types to make
+            # sure that we accept them and handle them properly
+            # try a set as input for the multilink
+            nid = self.db.issue.create(title="spam", nosy=sets.Set(u1))
+            if commit: self.db.commit()
+            self.assertEqual(self.db.issue.get(nid, "nosy"), [u1])
+            self.assertRaises(TypeError, self.db.issue.set, nid,
+                nosy='invalid type')
+            # test with a tuple
+            self.db.issue.set(nid, nosy=tuple())
+            if commit: self.db.commit()
+            self.assertEqual(self.db.issue.get(nid, "nosy"), [])
+            # make sure we accept a frozen set
+            self.db.issue.set(nid, nosy=sets.Set([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, 
+        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')
@@ -214,7 +320,29 @@ class DBTest(MyTestCase):
             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'))
+            self.assertNotEqual(b, date.Date('1970-1-1.00:00:00'))
+            # The 1970 date will fail for metakit -- it is used
+            # internally for storing NULL. The others would, too
+            # because metakit tries to convert date.timestamp to an int
+            # for storing and fails with an overflow.
+            for d in [date.Date (x) for x in '2038', '1970', '0033', '9999']:
+                self.db.issue.set(nid, deadline=d)
+                if commit: self.db.commit()
+                c = self.db.issue.get(nid, "deadline")
+                self.assertEqual(c, d)
+
+    def testDateLeapYear(self):
+        nid = self.db.issue.create(title='spam', status='1',
+            deadline=date.Date('2008-02-29'))
+        self.assertEquals(str(self.db.issue.get(nid, 'deadline')),
+            '2008-02-29.00:00:00')
+        self.assertEquals(self.db.issue.filter(None,
+            {'deadline': '2008-02-29'}), [nid])
+        self.db.issue.set(nid, deadline=date.Date('2008-03-01'))
+        self.assertEquals(str(self.db.issue.get(nid, 'deadline')),
+            '2008-03-01.00:00:00')
+        self.assertEquals(self.db.issue.filter(None,
+            {'deadline': '2008-02-29'}), [])
 
     def testDateUnset(self):
         for commit in (0,1):
@@ -228,7 +356,7 @@ class DBTest(MyTestCase):
 
     # Interval
     def testIntervalChange(self):
-        self.assertRaises(TypeError, self.db.issue.create, 
+        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')
@@ -257,11 +385,19 @@ class DBTest(MyTestCase):
             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)
@@ -342,18 +478,36 @@ class DBTest(MyTestCase):
         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')
-        # make sure the list is different 
+        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')
@@ -391,7 +545,7 @@ class DBTest(MyTestCase):
         self.db.rollback()
         self.assertEqual(num_files, self.db.numfiles())
         for i in range(10):
-            self.db.file.create(name="test", type="text/plain", 
+            self.db.file.create(name="test", type="text/plain",
                     content="hi %d"%(i))
             self.db.commit()
         num_files2 = self.db.numfiles()
@@ -410,6 +564,15 @@ class DBTest(MyTestCase):
         name2 = self.db.user.get('1', 'username')
         self.assertEqual(name1, name2)
 
+    def testDestroyBlob(self):
+        # destroy an uncommitted blob
+        f1 = self.db.file.create(content='hello', type="text/plain")
+        self.db.commit()
+        fn = self.db.filename('file', f1)
+        self.db.file.destroy(f1)
+        self.db.commit()
+        self.assertEqual(os.path.exists(fn), False)
+
     def testDestroyNoJournalling(self):
         self.innerTestDestroy(klass=self.db.session)
 
@@ -496,7 +659,7 @@ class DBTest(MyTestCase):
 
         #
         # key property
-        # 
+        #
         # key must be a String
         ar(TypeError, self.db.file.setkey, 'fooz')
         # key must exist
@@ -550,8 +713,43 @@ class DBTest(MyTestCase):
         # invalid boolean value
         ar(TypeError, self.db.user.set, nid, assignable='true')
 
-    def testJournals(self):
+    def testAuditors(self):
+        class test:
+            called = False
+            def call(self, *args): self.called = True
+        create = test()
+
+        self.db.user.audit('create', create.call)
         self.db.user.create(username="mary")
+        self.assertEqual(create.called, True)
+
+        set = test()
+        self.db.user.audit('set', set.call)
+        self.db.user.set('1', username="joe")
+        self.assertEqual(set.called, True)
+
+        retire = test()
+        self.db.user.audit('retire', retire.call)
+        self.db.user.retire('1')
+        self.assertEqual(retire.called, True)
+
+    def testAuditorTwo(self):
+        class test:
+            n = 0
+            def a(self, *args): self.call_a = self.n; self.n += 1
+            def b(self, *args): self.call_b = self.n; self.n += 1
+            def c(self, *args): self.call_c = self.n; self.n += 1
+        test = test()
+        self.db.user.audit('create', test.b, 1)
+        self.db.user.audit('create', test.a, 1)
+        self.db.user.audit('create', test.c, 2)
+        self.db.user.create(username="mary")
+        self.assertEqual(test.call_a, 0)
+        self.assertEqual(test.call_b, 1)
+        self.assertEqual(test.call_c, 2)
+
+    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()
@@ -580,14 +778,17 @@ class DBTest(MyTestCase):
         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('1', journaltag)
+        self.assertEqual(muid, journaltag)
         self.assertEqual('unlink', action)
         self.assertEqual(('issue', '1', 'assignedto'), params)
 
@@ -614,6 +815,7 @@ class DBTest(MyTestCase):
     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()
 
@@ -639,8 +841,9 @@ class DBTest(MyTestCase):
         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 frooz")
+        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), {})
@@ -649,7 +852,37 @@ class DBTest(MyTestCase):
         self.assertEquals(self.db.indexer.search(['flebble'], self.db.issue),
             {i1: {}, i2: {}})
 
-    def testReindexing(self):
+        # 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 testIndexerSearchingLink(self):
+        m1 = self.db.msg.create(content="one two")
+        i1 = self.db.issue.create(messages=[m1])
+        m2 = self.db.msg.create(content="two three")
+        i2 = self.db.issue.create(feedback=m2)
+        self.db.commit()
+        self.assertEquals(self.db.indexer.search(['two'], self.db.issue),
+            {i1: {'messages': [m1]}, i2: {'feedback': [m2]}})
+
+    def testIndexerSearchMulti(self):
+        m1 = self.db.msg.create(content="one two")
+        m2 = self.db.msg.create(content="two three")
+        i1 = self.db.issue.create(messages=[m1])
+        i2 = self.db.issue.create(spam=[m2])
+        self.db.commit()
+        self.assertEquals(self.db.indexer.search([], self.db.issue), {})
+        self.assertEquals(self.db.indexer.search(['one'], self.db.issue),
+            {i1: {'messages': [m1]}})
+        self.assertEquals(self.db.indexer.search(['two'], self.db.issue),
+            {i1: {'messages': [m1]}, i2: {'spam': [m2]}})
+        self.assertEquals(self.db.indexer.search(['three'], self.db.issue),
+            {i2: {'spam': [m2]}})
+
+    def testReindexingChange(self):
         search = self.db.indexer.search
         issue = self.db.issue
         i1 = issue.create(title="flebble plop")
@@ -664,6 +897,15 @@ class DBTest(MyTestCase):
         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()
@@ -676,6 +918,7 @@ class DBTest(MyTestCase):
         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),
@@ -688,6 +931,16 @@ class DBTest(MyTestCase):
         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")
@@ -701,6 +954,47 @@ class DBTest(MyTestCase):
         self.assertEquals(self.db.indexer.search(['flebble'], self.db.issue),
             {'1': {}})
 
+    def testIndexingOnImport(self):
+        # import a message
+        msgcontent = 'Glrk'
+        msgid = self.db.msg.import_list(['content', 'files', 'recipients'],
+                                        [repr(msgcontent), '[]', '[]'])
+        msg_filename = self.db.filename(self.db.msg.classname, msgid,
+                                        create=1)
+        support.ensureParentsExist(msg_filename)
+        msg_file = open(msg_filename, 'w')
+        msg_file.write(msgcontent)
+        msg_file.close()
+
+        # import a file
+        filecontent = 'Brrk'
+        fileid = self.db.file.import_list(['content'], [repr(filecontent)])
+        file_filename = self.db.filename(self.db.file.classname, fileid,
+                                         create=1)
+        support.ensureParentsExist(file_filename)
+        file_file = open(file_filename, 'w')
+        file_file.write(filecontent)
+        file_file.close()
+
+        # import an issue
+        title = 'Bzzt'
+        nodeid = self.db.issue.import_list(['title', 'messages', 'files',
+            'spam', 'nosy', 'superseder'], [repr(title), repr([msgid]),
+            repr([fileid]), '[]', '[]', '[]'])
+        self.db.commit()
+
+        # Content of title attribute is indexed
+        self.assertEquals(self.db.indexer.search([title], self.db.issue),
+            {str(nodeid):{}})
+        # Content of message is indexed
+        self.assertEquals(self.db.indexer.search([msgcontent], self.db.issue),
+            {str(nodeid):{'messages':[str(msgid)]}})
+        # Content of file is indexed
+        self.assertEquals(self.db.indexer.search([filecontent], self.db.issue),
+            {str(nodeid):{'files':[str(fileid)]}})
+
+
+
     #
     # searching tests follow
     #
@@ -742,6 +1036,15 @@ class DBTest(MyTestCase):
         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')
@@ -806,23 +1109,26 @@ class DBTest(MyTestCase):
 
     def filteringSetup(self):
         for user in (
-                {'username': 'bleep'},
-                {'username': 'blop'},
-                {'username': 'blorp'}):
+                {'username': 'bleep', 'age': 1},
+                {'username': 'blop', 'age': 1.5},
+                {'username': 'blorp', 'age': 2}):
             self.db.user.create(**user)
         iss = self.db.issue
+        file_content = ''.join([chr(i) for i in range(255)])
+        f = self.db.file.create(content=file_content)
         for issue in (
                 {'title': 'issue one', 'status': '2', 'assignedto': '1',
-                    'foo': date.Interval('1:10'), 
-                    'deadline': date.Date('2003-01-01.00:00')},
-                    {'title': 'issue two', 'status': '1', 'assignedto': '2',
-                    'foo': date.Interval('1d'), 
+                    'foo': date.Interval('1:10'), 'priority': '3',
                     'deadline': date.Date('2003-02-16.22:50')},
-                {'title': 'issue three', 'status': '1',
+                {'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'), 
-                    'nosy': ['1'], 'deadline': date.Date('2004-03-08')}):
+                    'foo': date.Interval('0:10'), 'priority': '2',
+                    'nosy': ['1','2','3'], 'deadline': date.Date('2004-03-08'),
+                    'files': [f]}):
             self.db.issue.create(**issue)
         self.db.commit()
         return self.assertEqual, self.db.issue.filter
@@ -831,20 +1137,47 @@ class DBTest(MyTestCase):
         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)), [])
+        ae(filt(None, {'id': '100'}, ('+','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)),
-            ['1', '2'])
+            [])
 
     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': [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 testFilteringMultilinkAndGroup(self):
+        """testFilteringMultilinkAndGroup:
+        See roundup Bug 1541128: apparently grouping by something and
+        searching a Multilink failed with MySQL 5.0
+        """
+        ae, filt = self.filteringSetup()
+        ae(filt(None, {'files': '1'}, ('-','activity'), ('+','status')), ['4'])
 
     def testFilteringRetired(self):
         ae, filt = self.filteringSetup()
@@ -853,102 +1186,508 @@ class DBTest(MyTestCase):
 
     def testFilteringMultilink(self):
         ae, filt = self.filteringSetup()
-        ae(filt(None, {'nosy': '2'}, ('+','id'), (None,None)), ['3'])
+        ae(filt(None, {'nosy': '3'}, ('+','id'), (None,None)), ['4'])
         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):
+    def testFilteringRangeBasic(self):
+        ae, filt = self.filteringSetup()
+        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'])
+
+    def testFilteringRangeTwoSyntaxes(self):
+        ae, filt = self.filteringSetup()
+        ae(filt(None, {'deadline': 'from 2003-02-16'}), ['1', '3', '4'])
+        ae(filt(None, {'deadline': '2003-02-16;'}), ['1', '3', '4'])
+
+    def testFilteringRangeYearMonthDay(self):
         ae, filt = self.filteringSetup()
-        # Date ranges
-        ae(filt(None, {'deadline': 'from 2003-02-10 to 2003-02-23'}), ['2','3'])
-        ae(filt(None, {'deadline': '2003-02-10; 2003-02-23'}), ['2','3'])
-        ae(filt(None, {'deadline': '; 2003-02-16'}), ['1'])
-        # Lets assume people won't invent a time machine, otherwise this test
-        # may fail :)
-        ae(filt(None, {'deadline': 'from 2003-02-16'}), ['2', '3', '4'])
-        ae(filt(None, {'deadline': '2003-02-16;'}), ['2', '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'}), ['2', '3'])
-        ae(filt(None, {'deadline': '2003-03'}), [])
-        ae(filt(None, {'deadline': '2003-02-16'}), ['2'])
+        ae(filt(None, {'deadline': '2003-02-16'}), ['1'])
         ae(filt(None, {'deadline': '2003-02-17'}), [])
-        # Interval ranges
+
+    def testFilteringRangeMonths(self):
+        ae, filt = self.filteringSetup()
+        for month in range(1, 13):
+            for n in range(1, month+1):
+                i = self.db.issue.create(title='%d.%d'%(month, n),
+                    deadline=date.Date('2001-%02d-%02d.00:00'%(month, n)))
+        self.db.commit()
+
+        for month in range(1, 13):
+            r = filt(None, dict(deadline='2001-%02d'%month))
+            assert len(r) == month, 'month %d != length %d'%(month, len(r))
+
+    def testFilteringRangeInterval(self):
+        ae, filt = self.filteringSetup()
         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'}), [])
 
+    def testFilteringRangeGeekInterval(self):
+        ae, filt = self.filteringSetup()
+        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 testFilteringStringSort(self):
+        # 1: 'issue one'
+        # 2: 'issue two'
+        # 3: 'issue three'
+        # 4: 'non four'
+        ae, filt = self.filteringSetup()
+        ae(filt(None, {}, ('+','title')), ['1', '3', '2', '4'])
+        ae(filt(None, {}, ('-','title')), ['4', '2', '3', '1'])
+        # Test string case: For now allow both, w/wo case matching.
+        # 1: 'issue one'
+        # 2: 'issue two'
+        # 3: 'Issue three'
+        # 4: 'non four'
+        self.db.issue.set('3', title='Issue three')
+        ae(filt(None, {}, ('+','title')), ['1', '3', '2', '4'])
+        ae(filt(None, {}, ('-','title')), ['4', '2', '3', '1'])
+        # Obscure bug in anydbm backend trying to convert to number
+        # 1: '1st issue'
+        # 2: '2'
+        # 3: 'Issue three'
+        # 4: 'non four'
+        self.db.issue.set('1', title='1st issue')
+        self.db.issue.set('2', title='2')
+        ae(filt(None, {}, ('+','title')), ['1', '2', '3', '4'])
+        ae(filt(None, {}, ('-','title')), ['4', '3', '2', '1'])
+
     def testFilteringMultilinkSort(self):
+        # 1: []                 Reverse:  1: []
+        # 2: []                           2: []
+        # 3: ['admin','fred']             3: ['fred','admin']
+        # 4: ['admin','bleep','fred']     4: ['fred','bleep','admin']
+        # Note the sort order for the multilink doen't change when
+        # reversing the sort direction due to the re-sorting of the
+        # multilink!
         ae, filt = self.filteringSetup()
         ae(filt(None, {}, ('+','nosy'), (None,None)), ['1', '2', '4', '3'])
-        ae(filt(None, {}, ('-','nosy'), (None,None)), ['3', '4', '1', '2'])
+        ae(filt(None, {}, ('-','nosy'), (None,None)), ['4', '3', '1', '2'])
+
+    def testFilteringMultilinkSortGroup(self):
+        # 1: status: 2 "in-progress" nosy: []
+        # 2: status: 1 "unread"      nosy: []
+        # 3: status: 1 "unread"      nosy: ['admin','fred']
+        # 4: status: 3 "testing"     nosy: ['admin','bleep','fred']
+        ae, filt = self.filteringSetup()
+        ae(filt(None, {}, ('+','nosy'), ('+','status')), ['1', '4', '2', '3'])
+        ae(filt(None, {}, ('-','nosy'), ('+','status')), ['1', '4', '3', '2'])
+        ae(filt(None, {}, ('+','nosy'), ('-','status')), ['2', '3', '4', '1'])
+        ae(filt(None, {}, ('-','nosy'), ('-','status')), ['3', '2', '4', '1'])
+        ae(filt(None, {}, ('+','status'), ('+','nosy')), ['1', '2', '4', '3'])
+        ae(filt(None, {}, ('-','status'), ('+','nosy')), ['2', '1', '4', '3'])
+        ae(filt(None, {}, ('+','status'), ('-','nosy')), ['4', '3', '1', '2'])
+        ae(filt(None, {}, ('-','status'), ('-','nosy')), ['4', '3', '2', '1'])
+
+    def testFilteringLinkSortGroup(self):
+        # 1: status: 2 -> 'i', priority: 3 -> 1
+        # 2: status: 1 -> 'u', priority: 3 -> 1
+        # 3: status: 1 -> 'u', priority: 2 -> 3
+        # 4: status: 3 -> 't', priority: 2 -> 3
+        ae, filt = self.filteringSetup()
+        ae(filt(None, {}, ('+','status'), ('+','priority')),
+            ['1', '2', '4', '3'])
+        ae(filt(None, {'priority':'2'}, ('+','status'), ('+','priority')),
+            ['4', '3'])
+        ae(filt(None, {'priority.order':'3'}, ('+','status'), ('+','priority')),
+            ['4', '3'])
+        ae(filt(None, {'priority':['2','3']}, ('+','priority'), ('+','status')),
+            ['1', '4', '2', '3'])
+        ae(filt(None, {}, ('+','priority'), ('+','status')),
+            ['1', '4', '2', '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'])
+
+    def filteringSetupTransitiveSearch(self):
+        u_m = {}
+        k = 30
+        for user in (
+                {'username': 'ceo', 'age': 129},
+                {'username': 'grouplead1', 'age': 29, 'supervisor': '3'},
+                {'username': 'grouplead2', 'age': 29, 'supervisor': '3'},
+                {'username': 'worker1', 'age': 25, 'supervisor' : '4'},
+                {'username': 'worker2', 'age': 24, 'supervisor' : '4'},
+                {'username': 'worker3', 'age': 23, 'supervisor' : '5'},
+                {'username': 'worker4', 'age': 22, 'supervisor' : '5'},
+                {'username': 'worker5', 'age': 21, 'supervisor' : '5'}):
+            u = self.db.user.create(**user)
+            u_m [u] = self.db.msg.create(author = u, content = ' '
+                , date = date.Date ('2006-01-%s' % k))
+            k -= 1
+        iss = self.db.issue
+        for issue in (
+                {'title': 'ts1', 'status': '2', 'assignedto': '6',
+                    'priority': '3', 'messages' : [u_m ['6']], 'nosy' : ['4']},
+                {'title': 'ts2', 'status': '1', 'assignedto': '6',
+                    'priority': '3', 'messages' : [u_m ['6']], 'nosy' : ['5']},
+                {'title': 'ts4', 'status': '2', 'assignedto': '7',
+                    'priority': '3', 'messages' : [u_m ['7']]},
+                {'title': 'ts5', 'status': '1', 'assignedto': '8',
+                    'priority': '3', 'messages' : [u_m ['8']]},
+                {'title': 'ts6', 'status': '2', 'assignedto': '9',
+                    'priority': '3', 'messages' : [u_m ['9']]},
+                {'title': 'ts7', 'status': '1', 'assignedto': '10',
+                    'priority': '3', 'messages' : [u_m ['10']]},
+                {'title': 'ts8', 'status': '2', 'assignedto': '10',
+                    'priority': '3', 'messages' : [u_m ['10']]},
+                {'title': 'ts9', 'status': '1', 'assignedto': '10',
+                    'priority': '3', 'messages' : [u_m ['10'], u_m ['9']]}):
+            self.db.issue.create(**issue)
+        return self.assertEqual, self.db.issue.filter
+
+    def testFilteringTransitiveLinkUser(self):
+        ae, filt = self.filteringSetupTransitiveSearch()
+        ufilt = self.db.user.filter
+        ae(ufilt(None, {'supervisor.username': 'ceo'}, ('+','username')),
+            ['4', '5'])
+        ae(ufilt(None, {'supervisor.supervisor.username': 'ceo'},
+            ('+','username')), ['6', '7', '8', '9', '10'])
+        ae(ufilt(None, {'supervisor.supervisor': '3'}, ('+','username')),
+            ['6', '7', '8', '9', '10'])
+        ae(ufilt(None, {'supervisor.supervisor.id': '3'}, ('+','username')),
+            ['6', '7', '8', '9', '10'])
+        ae(ufilt(None, {'supervisor.username': 'grouplead1'}, ('+','username')),
+            ['6', '7'])
+        ae(ufilt(None, {'supervisor.username': 'grouplead2'}, ('+','username')),
+            ['8', '9', '10'])
+        ae(ufilt(None, {'supervisor.username': 'grouplead2',
+            'supervisor.supervisor.username': 'ceo'}, ('+','username')),
+            ['8', '9', '10'])
+        ae(ufilt(None, {'supervisor.supervisor': '3', 'supervisor': '4'},
+            ('+','username')), ['6', '7'])
+
+    def testFilteringTransitiveLinkSort(self):
+        ae, filt = self.filteringSetupTransitiveSearch()
+        ufilt = self.db.user.filter
+        # Need to make ceo his own (and first two users') supervisor,
+        # otherwise we will depend on sorting order of NULL values.
+        # Leave that to a separate test.
+        self.db.user.set('1', supervisor = '3')
+        self.db.user.set('2', supervisor = '3')
+        self.db.user.set('3', supervisor = '3')
+        ae(ufilt(None, {'supervisor':'3'}, []), ['1', '2', '3', '4', '5'])
+        ae(ufilt(None, {}, [('+','supervisor.supervisor.supervisor'),
+            ('+','supervisor.supervisor'), ('+','supervisor'),
+            ('+','username')]),
+            ['1', '3', '2', '4', '5', '6', '7', '8', '9', '10'])
+        ae(ufilt(None, {}, [('+','supervisor.supervisor.supervisor'),
+            ('-','supervisor.supervisor'), ('-','supervisor'),
+            ('+','username')]),
+            ['8', '9', '10', '6', '7', '1', '3', '2', '4', '5'])
+        ae(filt(None, {}, [('+','assignedto.supervisor.supervisor.supervisor'),
+            ('+','assignedto.supervisor.supervisor'),
+            ('+','assignedto.supervisor'), ('+','assignedto')]),
+            ['1', '2', '3', '4', '5', '6', '7', '8'])
+        ae(filt(None, {}, [('+','assignedto.supervisor.supervisor.supervisor'),
+            ('+','assignedto.supervisor.supervisor'),
+            ('-','assignedto.supervisor'), ('+','assignedto')]),
+            ['4', '5', '6', '7', '8', '1', '2', '3'])
+        ae(filt(None, {}, [('+','assignedto.supervisor.supervisor.supervisor'),
+            ('+','assignedto.supervisor.supervisor'),
+            ('+','assignedto.supervisor'), ('+','assignedto'),
+            ('-','status')]),
+            ['2', '1', '3', '4', '5', '6', '8', '7'])
+        ae(filt(None, {}, [('+','assignedto.supervisor.supervisor.supervisor'),
+            ('+','assignedto.supervisor.supervisor'),
+            ('+','assignedto.supervisor'), ('+','assignedto'),
+            ('+','status')]),
+            ['1', '2', '3', '4', '5', '7', '6', '8'])
+        ae(filt(None, {}, [('+','assignedto.supervisor.supervisor.supervisor'),
+            ('+','assignedto.supervisor.supervisor'),
+            ('-','assignedto.supervisor'), ('+','assignedto'), ('+','status')]),
+            ['4', '5', '7', '6', '8', '1', '2', '3'])
+        ae(filt(None, {'assignedto':['6','7','8','9','10']},
+            [('+','assignedto.supervisor.supervisor.supervisor'),
+            ('+','assignedto.supervisor.supervisor'),
+            ('-','assignedto.supervisor'), ('+','assignedto'), ('+','status')]),
+            ['4', '5', '7', '6', '8', '1', '2', '3'])
+        ae(filt(None, {'assignedto':['6','7','8','9']},
+            [('+','assignedto.supervisor.supervisor.supervisor'),
+            ('+','assignedto.supervisor.supervisor'),
+            ('-','assignedto.supervisor'), ('+','assignedto'), ('+','status')]),
+            ['4', '5', '1', '2', '3'])
+
+    def testFilteringTransitiveLinkSortNull(self):
+        """Check sorting of NULL values"""
+        ae, filt = self.filteringSetupTransitiveSearch()
+        ufilt = self.db.user.filter
+        ae(ufilt(None, {}, [('+','supervisor.supervisor.supervisor'),
+            ('+','supervisor.supervisor'), ('+','supervisor'),
+            ('+','username')]),
+            ['1', '3', '2', '4', '5', '6', '7', '8', '9', '10'])
+        ae(ufilt(None, {}, [('+','supervisor.supervisor.supervisor'),
+            ('-','supervisor.supervisor'), ('-','supervisor'),
+            ('+','username')]),
+            ['8', '9', '10', '6', '7', '4', '5', '1', '3', '2'])
+        ae(filt(None, {}, [('+','assignedto.supervisor.supervisor.supervisor'),
+            ('+','assignedto.supervisor.supervisor'),
+            ('+','assignedto.supervisor'), ('+','assignedto')]),
+            ['1', '2', '3', '4', '5', '6', '7', '8'])
+        ae(filt(None, {}, [('+','assignedto.supervisor.supervisor.supervisor'),
+            ('+','assignedto.supervisor.supervisor'),
+            ('-','assignedto.supervisor'), ('+','assignedto')]),
+            ['4', '5', '6', '7', '8', '1', '2', '3'])
+
+    def testFilteringTransitiveLinkIssue(self):
+        ae, filt = self.filteringSetupTransitiveSearch()
+        ae(filt(None, {'assignedto.supervisor.username': 'grouplead1'},
+            ('+','id')), ['1', '2', '3'])
+        ae(filt(None, {'assignedto.supervisor.username': 'grouplead2'},
+            ('+','id')), ['4', '5', '6', '7', '8'])
+        ae(filt(None, {'assignedto.supervisor.username': 'grouplead2',
+                       'status': '1'}, ('+','id')), ['4', '6', '8'])
+        ae(filt(None, {'assignedto.supervisor.username': 'grouplead2',
+                       'status': '2'}, ('+','id')), ['5', '7'])
+        ae(filt(None, {'assignedto.supervisor.username': ['grouplead2'],
+                       'status': '2'}, ('+','id')), ['5', '7'])
+        ae(filt(None, {'assignedto.supervisor': ['4', '5'], 'status': '2'},
+            ('+','id')), ['1', '3', '5', '7'])
+
+    def testFilteringTransitiveMultilink(self):
+        ae, filt = self.filteringSetupTransitiveSearch()
+        ae(filt(None, {'messages.author.username': 'grouplead1'},
+            ('+','id')), [])
+        ae(filt(None, {'messages.author': '6'},
+            ('+','id')), ['1', '2'])
+        ae(filt(None, {'messages.author.id': '6'},
+            ('+','id')), ['1', '2'])
+        ae(filt(None, {'messages.author.username': 'worker1'},
+            ('+','id')), ['1', '2'])
+        ae(filt(None, {'messages.author': '10'},
+            ('+','id')), ['6', '7', '8'])
+        ae(filt(None, {'messages.author': '9'},
+            ('+','id')), ['5', '8'])
+        ae(filt(None, {'messages.author': ['9', '10']},
+            ('+','id')), ['5', '6', '7', '8'])
+        ae(filt(None, {'messages.author': ['8', '9']},
+            ('+','id')), ['4', '5', '8'])
+        ae(filt(None, {'messages.author': ['8', '9'], 'status' : '1'},
+            ('+','id')), ['4', '8'])
+        ae(filt(None, {'messages.author': ['8', '9'], 'status' : '2'},
+            ('+','id')), ['5'])
+        ae(filt(None, {'messages.author': ['8', '9', '10'],
+            'messages.date': '2006-01-22.21:00;2006-01-23'}, ('+','id')),
+            ['6', '7', '8'])
+        ae(filt(None, {'nosy.supervisor.username': 'ceo'},
+            ('+','id')), ['1', '2'])
+        ae(filt(None, {'messages.author': ['6', '9']},
+            ('+','id')), ['1', '2', '5', '8'])
+        ae(filt(None, {'messages': ['5', '7']},
+            ('+','id')), ['3', '5', '8'])
+        ae(filt(None, {'messages.author': ['6', '9'], 'messages': ['5', '7']},
+            ('+','id')), ['5', '8'])
+
+    def testFilteringTransitiveMultilinkSort(self):
+        ae, filt = self.filteringSetupTransitiveSearch()
+        ae(filt(None, {}, [('+','messages.author')]),
+            ['1', '2', '3', '4', '5', '8', '6', '7'])
+        ae(filt(None, {}, [('-','messages.author')]),
+            ['8', '6', '7', '5', '4', '3', '1', '2'])
+        ae(filt(None, {}, [('+','messages.date')]),
+            ['6', '7', '8', '5', '4', '3', '1', '2'])
+        ae(filt(None, {}, [('-','messages.date')]),
+            ['1', '2', '3', '4', '8', '5', '6', '7'])
+        ae(filt(None, {}, [('+','messages.author'),('+','messages.date')]),
+            ['1', '2', '3', '4', '5', '8', '6', '7'])
+        ae(filt(None, {}, [('-','messages.author'),('+','messages.date')]),
+            ['8', '6', '7', '5', '4', '3', '1', '2'])
+        ae(filt(None, {}, [('+','messages.author'),('-','messages.date')]),
+            ['1', '2', '3', '4', '5', '8', '6', '7'])
+        ae(filt(None, {}, [('-','messages.author'),('-','messages.date')]),
+            ['8', '6', '7', '5', '4', '3', '1', '2'])
+        ae(filt(None, {}, [('+','messages.author'),('+','assignedto')]),
+            ['1', '2', '3', '4', '5', '8', '6', '7'])
+        ae(filt(None, {}, [('+','messages.author'),
+            ('-','assignedto.supervisor'),('-','assignedto')]),
+            ['1', '2', '3', '4', '5', '8', '6', '7'])
+        ae(filt(None, {},
+            [('+','messages.author.supervisor.supervisor.supervisor'),
+            ('+','messages.author.supervisor.supervisor'),
+            ('+','messages.author.supervisor'), ('+','messages.author')]),
+            ['1', '2', '3', '4', '5', '6', '7', '8'])
+        self.db.user.setorderprop('age')
+        self.db.msg.setorderprop('date')
+        ae(filt(None, {}, [('+','messages'), ('+','messages.author')]),
+            ['6', '7', '8', '5', '4', '3', '1', '2'])
+        ae(filt(None, {}, [('+','messages.author'), ('+','messages')]),
+            ['6', '7', '8', '5', '4', '3', '1', '2'])
+        self.db.msg.setorderprop('author')
+        # Orderprop is a Link/Multilink:
+        # messages are sorted by orderprop().labelprop(), i.e. by
+        # author.username, *not* by author.orderprop() (author.age)!
+        ae(filt(None, {}, [('+','messages')]),
+            ['1', '2', '3', '4', '5', '8', '6', '7'])
+        ae(filt(None, {}, [('+','messages.author'), ('+','messages')]),
+            ['6', '7', '8', '5', '4', '3', '1', '2'])
+        # The following will sort by
+        # author.supervisor.username and then by
+        # author.username
+        # I've resited the tempation to implement recursive orderprop
+        # here: There could even be loops if several classes specify a
+        # Link or Multilink as the orderprop...
+        # msg: 4: worker1 (id  5) : grouplead1 (id 4) ceo (id 3)
+        # msg: 5: worker2 (id  7) : grouplead1 (id 4) ceo (id 3)
+        # msg: 6: worker3 (id  8) : grouplead2 (id 5) ceo (id 3)
+        # msg: 7: worker4 (id  9) : grouplead2 (id 5) ceo (id 3)
+        # msg: 8: worker5 (id 10) : grouplead2 (id 5) ceo (id 3)
+        # issue 1: messages 4   sortkey:[[grouplead1], [worker1], 1]
+        # issue 2: messages 4   sortkey:[[grouplead1], [worker1], 2]
+        # issue 3: messages 5   sortkey:[[grouplead1], [worker2], 3]
+        # issue 4: messages 6   sortkey:[[grouplead2], [worker3], 4]
+        # issue 5: messages 7   sortkey:[[grouplead2], [worker4], 5]
+        # issue 6: messages 8   sortkey:[[grouplead2], [worker5], 6]
+        # issue 7: messages 8   sortkey:[[grouplead2], [worker5], 7]
+        # issue 8: messages 7,8 sortkey:[[grouplead2, grouplead2], ...]
+        self.db.user.setorderprop('supervisor')
+        ae(filt(None, {}, [('+','messages.author'), ('-','messages')]),
+            ['3', '1', '2', '6', '7', '5', '4', '8'])
+
+    def testFilteringSortId(self):
+        ae, filt = self.filteringSetupTransitiveSearch()
+        ae(self.db.user.filter(None, {}, ('+','id')),
+            ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10'])
 
 # 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()
+        # Get some stuff into the journal for testing import/export of
+        # journal data:
+        self.db.user.set('4', password = password.Password('xyzzy'))
+        self.db.user.set('4', age = 3)
+        self.db.user.set('4', assignable = True)
+        self.db.issue.set('1', title = 'i1', status = '3')
+        self.db.issue.set('1', deadline = date.Date('2007'))
+        self.db.issue.set('1', foo = date.Interval('1:20'))
+        p = self.db.priority.create(name = 'some_prio_without_order')
+        self.db.commit()
+        self.db.user.set('4', password = password.Password('123xyzzy'))
+        self.db.user.set('4', assignable = False)
+        self.db.priority.set(p, order = '4711')
+        self.db.commit()
+
         self.db.user.retire('3')
         self.db.issue.retire('2')
 
         # grab snapshot of the current database
         orig = {}
+        origj = {}
         for cn,klass in self.db.classes.items():
             cl = orig[cn] = {}
+            jn = origj[cn] = {}
             for id in klass.list():
                 it = cl[id] = {}
+                jn[id] = self.db.getjournal(cn, id)
                 for name in klass.getprops().keys():
                     it[name] = klass.get(id, name)
 
-        # grab the export
-        export = {}
-        for cn,klass in self.db.classes.items():
-            names = klass.getprops().keys()
-            cl = export[cn] = [names+['is retired']]
-            for id in klass.getnodeids():
-                cl.append(klass.export_list(names, id))
-
-        # 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:]:
-                maxid = max(maxid, int(klass.import_list(names, itemprops)))
-            self.db.setid(cn, str(maxid+1))
+        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])
+            # This is needed, otherwise journals won't be there for anydbm
+            self.db.commit()
+        finally:
+            shutil.rmtree('_test_export')
 
         # compare with snapshot of the database
-        for cn, items in orig.items():
+        for cn, items in orig.iteritems():
             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)
+            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)
@@ -962,6 +1701,19 @@ class DBTest(MyTestCase):
                             raise
                         # don't get hung up on rounding errors
                         assert not l.__cmp__(value, int_seconds=1)
+        for jc, items in origj.iteritems():
+            for id, oj in items.iteritems():
+                rj = self.db.getjournal(jc, id)
+                # Both mysql and postgresql have some minor issues with
+                # rounded seconds on export/import, so we compare only
+                # the integer part.
+                for j in oj:
+                    j[1].second = float(int(j[1].second))
+                for j in rj:
+                    j[1].second = float(int(j[1].second))
+                oj.sort()
+                rj.sort()
+                ae(oj, rj)
 
         # make sure the retired items are actually imported
         ae(self.db.user.get('4', 'username'), 'blop')
@@ -972,14 +1724,6 @@ class DBTest(MyTestCase):
         newid = self.db.user.create(username='testing')
         assert newid > maxid
 
-    def testSafeGet(self):
-        # existent nodeid, existent property
-        self.assertEqual(self.db.user.safeget('1', 'username'), 'admin')
-        # nonexistent nodeid, existent property
-        self.assertEqual(self.db.user.safeget('999', 'username'), None)
-        # different default
-        self.assertEqual(self.db.issue.safeget('999', 'nosy', []), [])
-
     def testAddProperty(self):
         self.db.issue.create(title="spam", status='1')
         self.db.commit()
@@ -991,8 +1735,8 @@ class DBTest(MyTestCase):
         keys = props.keys()
         keys.sort()
         self.assertEqual(keys, ['activity', 'actor', 'assignedto', 'creation',
-            'creator', 'deadline', 'files', 'fixer', 'foo', 'id', 'messages',
-            'nosy', 'status', 'superseder', 'title'])
+            'creator', 'deadline', 'feedback', 'files', 'fixer', 'foo', 'id', 'messages',
+            'nosy', 'priority', 'spam', 'status', 'superseder', 'title'])
         self.assertEqual(self.db.issue.get('1', "fixer"), None)
 
     def testRemoveProperty(self):
@@ -1005,8 +1749,8 @@ class DBTest(MyTestCase):
         keys = props.keys()
         keys.sort()
         self.assertEqual(keys, ['activity', 'actor', 'assignedto', 'creation',
-            'creator', 'deadline', 'files', 'foo', 'id', 'messages',
-            'nosy', 'status', 'superseder'])
+            'creator', 'deadline', 'feedback', 'files', 'foo', 'id', 'messages',
+            'nosy', 'priority', 'spam', 'status', 'superseder'])
         self.assertEqual(self.db.issue.list(), ['1'])
 
     def testAddRemoveProperty(self):
@@ -1020,10 +1764,42 @@ class DBTest(MyTestCase):
         keys = props.keys()
         keys.sort()
         self.assertEqual(keys, ['activity', 'actor', 'assignedto', 'creation',
-            'creator', 'deadline', 'files', 'fixer', 'foo', 'id', 'messages',
-            'nosy', 'status', 'superseder'])
+            'creator', 'deadline', 'feedback', 'files', 'fixer', 'foo', 'id',
+            'messages', 'nosy', 'priority', 'spam', 'status', 'superseder'])
         self.assertEqual(self.db.issue.list(), ['1'])
 
+    def testNosyMail(self) :
+        """Creates one issue with two attachments, one smaller and one larger
+           than the set max_attachment_size.
+        """
+        db = self.db
+        db.config.NOSY_MAX_ATTACHMENT_SIZE = 4096
+        res = dict(mail_to = None, mail_msg = None)
+        def dummy_snd(s, to, msg, res=res) :
+            res["mail_to"], res["mail_msg"] = to, msg
+        backup, Mailer.smtp_send = Mailer.smtp_send, dummy_snd
+        try :
+            f1 = db.file.create(name="test1.txt", content="x" * 20)
+            f2 = db.file.create(name="test2.txt", content="y" * 5000)
+            m  = db.msg.create(content="one two", author="admin",
+                files = [f1, f2])
+            i  = db.issue.create(title='spam', files = [f1, f2],
+                messages = [m], nosy = [db.user.lookup("fred")])
+
+            db.issue.nosymessage(i, m, {})
+            mail_msg = res["mail_msg"].getvalue()
+            self.assertEqual(res["mail_to"], ["fred@example.com"])
+            self.failUnless("From: admin" in mail_msg)
+            self.failUnless("Subject: [issue1] spam" in mail_msg)
+            self.failUnless("New submission from admin" in mail_msg)
+            self.failUnless("one two" in mail_msg)
+            self.failIf("File 'test1.txt' not attached" in mail_msg)
+            self.failUnless(base64.encodestring("xxx").rstrip() in mail_msg)
+            self.failUnless("File 'test2.txt' not attached" in mail_msg)
+            self.failIf(base64.encodestring("yyy").rstrip() in mail_msg)
+        finally :
+            Mailer.smtp_send = backup
+
 class ROTest(MyTestCase):
     def setUp(self):
         # remove previous test, ignore errors
@@ -1071,6 +1847,14 @@ class SchemaTest(MyTestCase):
         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())
@@ -1111,7 +1895,9 @@ class SchemaTest(MyTestCase):
 
     def init_amod(self):
         self.db = self.module.Database(config, 'admin')
-        a = self.module.Class(self.db, "a", name=String(), fooz=String())
+        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")
@@ -1128,18 +1914,24 @@ class SchemaTest(MyTestCase):
         # modify "a" schema
         self.init_amod()
         self.assertEqual(self.db.a.get(aid, 'name'), 'apple')
-        self.assertEqual(self.db.a.get(aid, 'fooz'), None)
+        self.assertEqual(self.db.a.get(aid, 'newstr'), None)
+        self.assertEqual(self.db.a.get(aid, 'newint'), None)
+        # hack - metakit can't return None for missing values, and we're not
+        # really checking for that behavior here anyway
+        self.assert_(not self.db.a.get(aid, 'newnum'))
+        self.assert_(not self.db.a.get(aid, 'newbool'))
+        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', fooz='booz')
+        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, 'fooz'), None)
+        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, 'fooz'), 'booz')
+        self.assertEqual(self.db.a.get(aid2, 'newstr'), 'booz')
 
         # confirm journal's ok
         self.db.getjournal('a', aid)
@@ -1147,8 +1939,8 @@ class SchemaTest(MyTestCase):
 
     def init_amodkey(self):
         self.db = self.module.Database(config, 'admin')
-        a = self.module.Class(self.db, "a", name=String(), fooz=String())
-        a.setkey("fooz")
+        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()
@@ -1159,12 +1951,12 @@ class SchemaTest(MyTestCase):
         self.assertEqual(self.db.a.lookup('apple'), aid)
         self.db.commit(); self.db.close()
 
-        # change the key to fooz on a
+        # 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, 'fooz'), None)
+        self.assertEqual(self.db.a.get(aid, 'newstr'), None)
         self.assertRaises(KeyError, self.db.a.lookup, 'apple')
-        aid2 = self.db.a.create(name='aardvark', fooz='booz')
+        aid2 = self.db.a.create(name='aardvark', newstr='booz')
         self.db.commit(); self.db.close()
 
         # check
@@ -1174,10 +1966,24 @@ class SchemaTest(MyTestCase):
         # 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(),
-            fooz=Multilink('a'))
+            newml=Multilink('a'))
         a.setkey('name')
         self.db.post_init()
 
@@ -1189,14 +1995,14 @@ class SchemaTest(MyTestCase):
 
         # add a multilink prop
         self.init_amodml()
-        bid = self.db.a.create(name='bear', fooz=[aid])
-        self.assertEqual(self.db.a.find(fooz=aid), [bid])
+        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(fooz=aid), [bid])
+        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)
 
@@ -1208,8 +2014,8 @@ class SchemaTest(MyTestCase):
         # add a multilink prop
         self.init_amodml()
         aid = self.db.a.create(name='apple')
-        bid = self.db.a.create(name='bear', fooz=[aid])
-        self.assertEqual(self.db.a.find(fooz=aid), [bid])
+        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()
@@ -1253,7 +2059,6 @@ class RDBMSTest:
 class ClassicInitTest(unittest.TestCase):
     count = 0
     db = None
-    extra_config = ''
 
     def setUp(self):
         ClassicInitTest.count = ClassicInitTest.count + 1
@@ -1266,33 +2071,22 @@ class ClassicInitTest(unittest.TestCase):
     def testCreation(self):
         ae = self.assertEqual
 
-        # create the instance
-        init.install(self.dirname, 'templates/classic')
-        init.write_select_db(self.dirname, self.backend)
-
-        if self.extra_config:
-            f = open(os.path.join(self.dirname, 'config.py'), 'a')
-            try:
-                f.write(self.extra_config)
-            finally:
-                f.close()
-        
-        init.initialise(self.dirname, 'sekrit')
-
-        # check we can load the package
-        instance = imp.load_package(self.dirname, self.dirname)
-
-        # and open the database
-        db = self.db = instance.open()
+        # 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()
+        l.sort()
         ae(l, ['1', '2', '3', '4', '5'])
         l = db.status.list()
+        l.sort()
         ae(l, ['1', '2', '3', '4', '5', '6', '7', '8'])
         l = db.keyword.list()
         ae(l, [])
         l = db.user.list()
+        l.sort()
         ae(l, ['1', '2'])
         l = db.msg.list()
         ae(l, [])
@@ -1309,3 +2103,4 @@ class ClassicInitTest(unittest.TestCase):
         except OSError, error:
             if error.errno not in (errno.ENOENT, errno.ESRCH): raise
 
+# vim: set et sts=4 sw=4 :
diff --git a/test/mocknull.py b/test/mocknull.py
new file mode 100644 (file)
index 0000000..e21d16f
--- /dev/null
@@ -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
index 0728246b1c2a26d41d7f9133fb0b5945fec12f9d..82fd6b43cf6bbb54de6cb94396ee0f0d44f306de 100755 (executable)
-from __future__ import nested_scopes\r
-\r
-import unittest\r
-from cgi import FieldStorage, MiniFieldStorage\r
-\r
-from roundup import hyperdb\r
-from roundup.date import Date, Interval\r
-from roundup.cgi.actions import *\r
-from roundup.cgi.exceptions import Redirect, Unauthorised, SeriousError\r
-\r
-class MockNull:\r
-    def __init__(self, **kwargs):\r
-        for key, value in kwargs.items():\r
-            self.__dict__[key] = value\r
-\r
-    def __call__(self, *args, **kwargs): return MockNull()\r
-    def __getattr__(self, name):\r
-        # This allows assignments which assume all intermediate steps are Null\r
-        # objects if they don't exist yet.\r
-        #\r
-        # For example (with just 'client' defined):\r
-        #\r
-        # client.db.config.TRACKER_WEB = 'BASE/'\r
-        self.__dict__[name] = MockNull()\r
-        return getattr(self, name)\r
-\r
-    def __getitem__(self, key): return self\r
-    def __nonzero__(self): return 0\r
-    def __str__(self): return ''\r
-    def __repr__(self): return '<MockNull 0x%x>'%id(self)\r
-\r
-def true(*args, **kwargs):\r
-    return 1\r
-\r
-class ActionTestCase(unittest.TestCase):\r
-    def setUp(self):\r
-        self.form = FieldStorage()\r
-        self.client = MockNull()\r
-        self.client.form = self.form\r
-\r
-class ShowActionTestCase(ActionTestCase):\r
-    def assertRaisesMessage(self, exception, callable, message, *args,\r
-                            **kwargs):\r
-        """An extension of assertRaises, which also checks the exception\r
-        message. We need this because we rely on exception messages when\r
-        redirecting.\r
-        """\r
-        try:\r
-            callable(*args, **kwargs)\r
-        except exception, msg:\r
-            self.assertEqual(str(msg), message)\r
-        else:\r
-            if hasattr(excClass,'__name__'):\r
-                excName = excClass.__name__\r
-            else:\r
-                excName = str(excClass)\r
-            raise self.failureException, excName\r
-\r
-    def testShowAction(self):\r
-        self.client.base = 'BASE/'\r
-\r
-        action = ShowAction(self.client)\r
-        self.assertRaises(ValueError, action.handle)\r
-\r
-        self.form.value.append(MiniFieldStorage('@type', 'issue'))\r
-        self.assertRaises(SeriousError, action.handle)\r
-\r
-        self.form.value.append(MiniFieldStorage('@number', '1'))\r
-        self.assertRaisesMessage(Redirect, action.handle, 'BASE/issue1')\r
-\r
-    def testShowActionNoType(self):\r
-        action = ShowAction(self.client)\r
-        self.assertRaises(ValueError, action.handle)\r
-        self.form.value.append(MiniFieldStorage('@number', '1'))\r
-        self.assertRaisesMessage(ValueError, action.handle,\r
-            'No type specified')\r
-\r
-class RetireActionTestCase(ActionTestCase):\r
-    def testRetireAction(self):\r
-        self.client.db.security.hasPermission = true\r
-        self.client.ok_message = []\r
-        RetireAction(self.client).handle()\r
-        self.assert_(len(self.client.ok_message) == 1)\r
-\r
-    def testNoPermission(self):\r
-        self.assertRaises(Unauthorised, RetireAction(self.client).execute)\r
-\r
-    def testDontRetireAdminOrAnonymous(self):\r
-        self.client.db.security.hasPermission=true\r
-        # look up the user class\r
-        self.client.classname = 'user'\r
-        # but always look up admin, regardless of nodeid\r
-        self.client.db.user.get = lambda a,b: 'admin'\r
-        self.assertRaises(ValueError, RetireAction(self.client).handle)\r
-        # .. or anonymous\r
-        self.client.db.user.get = lambda a,b: 'anonymous'\r
-        self.assertRaises(ValueError, RetireAction(self.client).handle)\r
-\r
-class SearchActionTestCase(ActionTestCase):\r
-    def setUp(self):\r
-        ActionTestCase.setUp(self)\r
-        self.action = SearchAction(self.client)\r
-\r
-class StandardSearchActionTestCase(SearchActionTestCase):\r
-    def testNoPermission(self):\r
-        self.assertRaises(Unauthorised, self.action.execute)\r
-\r
-    def testQueryName(self):\r
-        self.assertEqual(self.action.getQueryName(), '')\r
-\r
-        self.form.value.append(MiniFieldStorage('@queryname', 'foo'))\r
-        self.assertEqual(self.action.getQueryName(), 'foo')\r
-\r
-class FakeFilterVarsTestCase(SearchActionTestCase):\r
-    def setUp(self):\r
-        SearchActionTestCase.setUp(self)\r
-        self.client.db.classes.getprops = lambda: {'foo': hyperdb.Multilink('foo')}\r
-\r
-    def assertFilterEquals(self, expected):\r
-        self.action.fakeFilterVars()\r
-        self.assertEqual(self.form.getvalue('@filter'), expected)\r
-\r
-    def testEmptyMultilink(self):\r
-        self.form.value.append(MiniFieldStorage('foo', ''))\r
-        self.form.value.append(MiniFieldStorage('foo', ''))\r
-\r
-        self.assertFilterEquals(None)\r
-\r
-    def testNonEmptyMultilink(self):\r
-        self.form.value.append(MiniFieldStorage('foo', ''))\r
-        self.form.value.append(MiniFieldStorage('foo', '1'))\r
-\r
-        self.assertFilterEquals('foo')\r
-\r
-    def testEmptyKey(self):\r
-        self.form.value.append(MiniFieldStorage('foo', ''))\r
-        self.assertFilterEquals(None)\r
-\r
-    def testStandardKey(self):\r
-        self.form.value.append(MiniFieldStorage('foo', '1'))\r
-        self.assertFilterEquals('foo')\r
-\r
-    def testStringKey(self):\r
-        self.client.db.classes.getprops = lambda: {'foo': hyperdb.String()}\r
-        self.form.value.append(MiniFieldStorage('foo', 'hello'))\r
-        self.assertFilterEquals('foo')\r
-\r
-    def testTokenizedStringKey(self):\r
-        self.client.db.classes.getprops = lambda: {'foo': hyperdb.String()}\r
-        self.form.value.append(MiniFieldStorage('foo', 'hello world'))\r
-\r
-        self.assertFilterEquals('foo')\r
-\r
-        # The single value gets replaced with the tokenized list.\r
-        self.assertEqual([x.value for x in self.form['foo']], ['hello', 'world'])\r
-\r
-class CollisionDetectionTestCase(ActionTestCase):\r
-    def setUp(self):\r
-        ActionTestCase.setUp(self)\r
-        self.action = EditItemAction(self.client)\r
-        self.now = Date('.')\r
-        # round off for testing\r
-        self.now.second = int(self.now.second)\r
-\r
-    def testLastUserActivity(self):\r
-        self.assertEqual(self.action.lastUserActivity(), None)\r
-\r
-        self.client.form.value.append(MiniFieldStorage('@lastactivity', str(self.now)))\r
-        self.assertEqual(self.action.lastUserActivity(), self.now)\r
-\r
-    def testLastNodeActivity(self):\r
-        self.action.classname = 'issue'\r
-        self.action.nodeid = '1'\r
-\r
-        def get(nodeid, propname):\r
-            self.assertEqual(nodeid, '1')\r
-            self.assertEqual(propname, 'activity')\r
-            return self.now\r
-        self.client.db.issue.get = get\r
-\r
-        self.assertEqual(self.action.lastNodeActivity(), self.now)\r
-\r
-    def testCollision(self):\r
-        self.failUnless(self.action.detectCollision(self.now, self.now + Interval("1d")))\r
-        self.failIf(self.action.detectCollision(self.now, self.now - Interval("1d")))\r
-        self.failIf(self.action.detectCollision(None, self.now))\r
-\r
-def test_suite():\r
-    suite = unittest.TestSuite()\r
-    suite.addTest(unittest.makeSuite(RetireActionTestCase))\r
-    suite.addTest(unittest.makeSuite(StandardSearchActionTestCase))\r
-    suite.addTest(unittest.makeSuite(FakeFilterVarsTestCase))\r
-    suite.addTest(unittest.makeSuite(ShowActionTestCase))\r
-    suite.addTest(unittest.makeSuite(CollisionDetectionTestCase))\r
-    return suite\r
-\r
-if __name__ == '__main__':\r
-    runner = unittest.TextTestRunner()\r
-    unittest.main(testRunner=runner)\r
-\r
+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.get_transitive_prop = lambda x: \
+            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.get_transitive_prop = lambda x: 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')
+
+class EditItemActionTestCase(ActionTestCase):
+    def setUp(self):
+        ActionTestCase.setUp(self)
+        self.result = []
+        class AppendResult:
+            def __init__(inner_self, name):
+                inner_self.name = name
+            def __call__(inner_self, *args, **kw):
+                self.result.append((inner_self.name, args, kw))
+                if inner_self.name == 'set':
+                    return kw
+                return '17'
+
+        self.client.db.security.hasPermission = true
+        self.client.classname = 'issue'
+        self.client.base = 'http://tracker/'
+        self.client.nodeid = '4711'
+        self.client.template = 'item'
+        self.client.db.classes.create = AppendResult('create')
+        self.client.db.classes.set = AppendResult('set')
+        self.client.db.classes.getprops = lambda: \
+            ({'messages':hyperdb.Multilink('msg')
+             ,'content':hyperdb.String()
+             ,'files':hyperdb.Multilink('file')
+             })
+        self.action = EditItemAction(self.client)
+
+    def testMessageAttach(self):
+        expect = \
+            [ ('create',(),{'content':'t'})
+            , ('set',('4711',), {'messages':['23','42','17']})
+            ]
+        self.client.db.classes.get = lambda a, b:['23','42']
+        self.client.parsePropsFromForm = lambda: \
+            ( {('msg','-1'):{'content':'t'},('issue','4711'):{}}
+            , [('issue','4711','messages',[('msg','-1')])]
+            )
+        try :
+            self.action.handle()
+        except Redirect, msg:
+            pass
+        self.assertEqual(expect, self.result)
+
+    def testFileAttach(self):
+        expect = \
+            [('create',(),{'content':'t','type':'text/plain','name':'t.txt'})
+            ,('set',('4711',),{'files':['23','42','17']})
+            ]
+        self.client.db.classes.get = lambda a, b:['23','42']
+        self.client.parsePropsFromForm = lambda: \
+            ( {('file','-1'):{'content':'t','type':'text/plain','name':'t.txt'}
+              ,('issue','4711'):{}
+              }
+            , [('issue','4711','messages',[('msg','-1')])
+              ,('issue','4711','files',[('file','-1')])
+              ,('msg','-1','files',[('file','-1')])
+              ]
+            )
+        try :
+            self.action.handle()
+        except Redirect, msg:
+            pass
+        self.assertEqual(expect, self.result)
+
+    def testLinkExisting(self):
+        expect = [('set',('4711',),{'messages':['23','42','1']})]
+        self.client.db.classes.get = lambda a, b:['23','42']
+        self.client.parsePropsFromForm = lambda: \
+            ( {('issue','4711'):{},('msg','1'):{}}
+            , [('issue','4711','messages',[('msg','1')])]
+            )
+        try :
+            self.action.handle()
+        except Redirect, msg:
+            pass
+        self.assertEqual(expect, self.result)
+
+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))
+    suite.addTest(unittest.makeSuite(EditItemActionTestCase))
+    return suite
+
+if __name__ == '__main__':
+    runner = unittest.TextTestRunner()
+    unittest.main(testRunner=runner)
+
+# vim: set et sts=4 sw=4 :
index cb91c975db008eead4f7c4bdc80f6ce5f48bfe59..fa18eb3994caf71517eb780067273b76de60061b 100644 (file)
 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 # 
-# $Id: test_anydbm.py,v 1.3 2004-03-18 01:58:46 richard Exp $ 
+# $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:
-    from roundup.backends import anydbm as module
+    module = get_backend('anydbm')
 
     def nuke_database(self):
         shutil.rmtree(config.DATABASE)
diff --git a/test/test_bsddb.py b/test/test_bsddb.py
deleted file mode 100644 (file)
index ef81a3b..0000000
+++ /dev/null
@@ -1,64 +0,0 @@
-#
-# 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_bsddb.py,v 1.3 2004-03-18 01:58:46 richard Exp $ 
-
-import unittest, os, shutil, time
-
-from db_test_base import DBTest, ROTest, SchemaTest, ClassicInitTest, config
-from roundup import backends
-
-class bsddbOpener:
-    if hasattr(backends, 'bsddb'):
-        from roundup.backends import bsddb as module
-
-    def nuke_database(self):
-        shutil.rmtree(config.DATABASE)
-
-class bsddbDBTest(bsddbOpener, DBTest):
-    pass
-
-class bsddbROTest(bsddbOpener, ROTest):
-    pass
-
-class bsddbSchemaTest(bsddbOpener, SchemaTest):
-    pass
-
-class bsddbClassicInitTest(ClassicInitTest):
-    backend = 'bsddb'
-
-from session_common import DBMTest
-class bsddbSessionTest(bsddbOpener, DBMTest):
-    pass
-
-def test_suite():
-    suite = unittest.TestSuite()
-    if not hasattr(backends, 'bsddb'):
-        print 'Skipping bsddb tests'
-        return suite
-    print 'Including bsddb tests'
-    suite.addTest(unittest.makeSuite(bsddbDBTest))
-    suite.addTest(unittest.makeSuite(bsddbROTest))
-    suite.addTest(unittest.makeSuite(bsddbSchemaTest))
-    suite.addTest(unittest.makeSuite(bsddbClassicInitTest))
-    suite.addTest(unittest.makeSuite(bsddbSessionTest))
-    return suite
-
-if __name__ == '__main__':
-    runner = unittest.TextTestRunner()
-    unittest.main(testRunner=runner)
-
diff --git a/test/test_bsddb3.py b/test/test_bsddb3.py
deleted file mode 100644 (file)
index 3469fdd..0000000
+++ /dev/null
@@ -1,64 +0,0 @@
-#
-# 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_bsddb3.py,v 1.3 2004-03-18 01:58:46 richard Exp $ 
-
-import unittest, os, shutil, time
-
-from db_test_base import DBTest, ROTest, SchemaTest, ClassicInitTest, config
-from roundup import backends
-
-class bsddb3Opener:
-    if hasattr(backends, 'bsddb3'):
-        from roundup.backends import bsddb3 as module
-
-    def nuke_database(self):
-        shutil.rmtree(config.DATABASE)
-
-class bsddb3DBTest(bsddb3Opener, DBTest):
-    pass
-
-class bsddb3ROTest(bsddb3Opener, ROTest):
-    pass
-
-class bsddb3SchemaTest(bsddb3Opener, SchemaTest):
-    pass
-
-class bsddb3ClassicInitTest(ClassicInitTest):
-    backend = 'bsddb3'
-
-from session_common import DBMTest
-class bsddb3SessionTest(bsddb3Opener, DBMTest):
-    pass
-
-def test_suite():
-    suite = unittest.TestSuite()
-    if not hasattr(backends, 'bsddb3'):
-        print 'Skipping bsddb3 tests'
-        return suite
-    print 'Including bsddb3 tests'
-    suite.addTest(unittest.makeSuite(bsddb3DBTest))
-    suite.addTest(unittest.makeSuite(bsddb3ROTest))
-    suite.addTest(unittest.makeSuite(bsddb3SchemaTest))
-    suite.addTest(unittest.makeSuite(bsddb3ClassicInitTest))
-    suite.addTest(unittest.makeSuite(bsddb3SessionTest))
-    return suite
-
-if __name__ == '__main__':
-    runner = unittest.TextTestRunner()
-    unittest.main(testRunner=runner)
-
index c8494a0b7e91c11e3d3df7eb4f26b796eadff70e..5929f5b323c470683918d34ad32c3cf7f00a2da9 100644 (file)
@@ -8,15 +8,18 @@
 # but WITHOUT ANY WARRANTY; without even the implied warranty of
 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 #
-# $Id: test_cgi.py,v 1.23 2004-02-17 03:48:08 richard Exp $
+# $Id: test_cgi.py,v 1.36 2008-08-07 06:12:57 richard Exp $
 
 import unittest, os, shutil, errno, sys, difflib, cgi, re
 
-from roundup.cgi import client
+from roundup.cgi import client, actions, exceptions
 from roundup.cgi.exceptions import FormError
+from roundup.cgi.templating import HTMLItem
 from roundup.cgi.form_parser import FormParser
 from roundup import init, instance, password, hyperdb, date
 
+import db_test_base
+
 NEEDS_INSTANCE = 1
 
 class FileUpload:
@@ -37,10 +40,6 @@ def makeForm(args):
             form.list.append(cgi.MiniFieldStorage(k, v))
     return form
 
-class config:
-    TRACKER_NAME = 'testing testing'
-    TRACKER_WEB = 'http://testing.testing/'
-
 cm = client.clean_message
 class MessageTestCase(unittest.TestCase):
     def testCleanMessageOK(self):
@@ -64,29 +63,21 @@ class MessageTestCase(unittest.TestCase):
 class FormTestCase(unittest.TestCase):
     def setUp(self):
         self.dirname = '_test_cgi_form'
-        try:
-            shutil.rmtree(self.dirname)
-        except OSError, error:
-            if error.errno not in (errno.ENOENT, errno.ESRCH): raise
-        # create the instance
-        init.install(self.dirname, 'templates/classic')
-        init.write_select_db(self.dirname, 'anydbm')
-        init.initialise(self.dirname, 'sekrit')
-        
-        # check we can load the package
-        self.instance = instance.open(self.dirname)
-        # and open the database
+        # 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@bork.bork.bork',
             realname='Bork, Chef', roles='User')
-        self.db.user.create(username='mary', address='mary@test',
+        self.db.user.create(username='mary', address='mary@test.test',
             roles='User', realname='Contrary, Mary')
 
-        test = self.instance.dbinit.Class(self.db, "test",
+        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())
+            messages=hyperdb.Multilink('msg'), interval=hyperdb.Interval())
 
         # compile the labels re
         classes = '|'.join(self.db.classes.keys())
@@ -98,6 +89,7 @@ class FormTestCase(unittest.TestCase):
             makeForm(form))
         cl.classname = classname
         cl.nodeid = nodeid
+        cl.language = ('en',)
         cl.db = self.db
         return cl.parsePropsFromForm(create=1)
 
@@ -162,6 +154,10 @@ class FormTestCase(unittest.TestCase):
             {':required': 'status', 'status':''}, 'issue')
         self.assertRaises(FormError, self.parseForm,
             {':required': 'nosy', 'nosy':''}, 'issue')
+        self.assertRaises(FormError, self.parseForm,
+            {':required': 'msg-1@content', 'msg-1@content':''}, 'issue')
+        self.assertRaises(FormError, self.parseForm,
+            {':required': 'msg-1@content'}, 'issue')
 
     #
     # Nonexistant edit
@@ -197,6 +193,44 @@ class FormTestCase(unittest.TestCase):
         self.assertEqual(self.parseForm({'title': ' '}, 'issue', nodeid),
             ({('issue', nodeid): {'title': None}}, []))
 
+    def testStringLinkId(self):
+        self.db.status.set('1', name='2')
+        self.db.status.set('2', name='1')
+        issue = self.db.issue.create(title='i1-status1', status='1')
+        self.assertEqual(self.db.issue.get(issue,'status'),'1')
+        self.assertEqual(self.db.status.lookup('1'),'2')
+        self.assertEqual(self.db.status.lookup('2'),'1')
+        form = cgi.FieldStorage()
+        cl = client.Client(self.instance, None, {'PATH_INFO':'/'}, form)
+        cl.classname = 'issue'
+        cl.nodeid = issue
+        cl.db = self.db
+        cl.language = ('en',)
+        item = HTMLItem(cl, 'issue', issue)
+        self.assertEqual(item.status.id, '1')
+        self.assertEqual(item.status.name, '2')
+
+    def testStringMultilinkId(self):
+        id = self.db.keyword.create(name='2')
+        self.assertEqual(id,'1')
+        id = self.db.keyword.create(name='1')
+        self.assertEqual(id,'2')
+        issue = self.db.issue.create(title='i1-status1', keyword=['1'])
+        self.assertEqual(self.db.issue.get(issue,'keyword'),['1'])
+        self.assertEqual(self.db.keyword.lookup('1'),'2')
+        self.assertEqual(self.db.keyword.lookup('2'),'1')
+        form = cgi.FieldStorage()
+        cl = client.Client(self.instance, None, {'PATH_INFO':'/'}, form)
+        cl.classname = 'issue'
+        cl.nodeid = issue
+        cl.db = self.db
+        cl.language = ('en',)
+        cl.userid = '1'
+        item = HTMLItem(cl, 'issue', issue)
+        for keyword in item.keyword:
+            self.assertEqual(keyword.id, '1')
+            self.assertEqual(keyword.name, '2')
+
     def testFileUpload(self):
         file = FileUpload('foo', 'foo.txt')
         self.assertEqual(self.parseForm({'content': file}, 'file'),
@@ -273,15 +307,16 @@ class FormTestCase(unittest.TestCase):
         cl.classname = 'issue'
         cl.nodeid = None
         cl.db = self.db
-        self.assertEqual(cl.parsePropsFromForm(create=1), 
+        cl.language = ('en',)
+        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), 
+        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), 
+        self.assertEqual(self.parseForm({'nosy': ' '}, 'issue', nodeid),
             ({('issue', nodeid): {'nosy': []}}, []))
         self.assertEqual(self.parseForm({'nosy': '1,2'}, 'issue', nodeid),
             ({('issue', nodeid): {}}, []))
@@ -418,6 +453,14 @@ class FormTestCase(unittest.TestCase):
         self.assertEqual(self.parseForm({'boolean': ' '}, 'test', nodeid),
             ({('test', nodeid): {'boolean': None}}, []))
 
+    def testRequiredBoolean(self):
+        self.assertRaises(FormError, self.parseForm, {'boolean': '',
+            ':required': 'boolean'})
+        try:
+            self.parseForm({'boolean': 'no', ':required': 'boolean'})
+        except FormError:
+            self.fail('boolean "no" raised "required missing"')
+
     #
     # Number
     #
@@ -434,15 +477,30 @@ class FormTestCase(unittest.TestCase):
     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),
@@ -451,6 +509,14 @@ class FormTestCase(unittest.TestCase):
         self.assertEqual(self.parseForm({'number': ' '}, 'test', nodeid),
             ({('test', nodeid): {'number': None}}, []))
 
+    def testRequiredNumber(self):
+        self.assertRaises(FormError, self.parseForm, {'number': '',
+            ':required': 'number'})
+        try:
+            self.parseForm({'number': '0', ':required': 'number'})
+        except FormError:
+            self.fail('number "no" raised "required missing"')
+
     #
     # Date
     #
@@ -468,15 +534,15 @@ class FormTestCase(unittest.TestCase):
         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', 
+        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), 
+        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), 
+        self.assertEqual(self.parseForm({'date': ' '}, 'test', nodeid),
             ({('test', nodeid): {'date': None}}, []))
 
     #
@@ -502,13 +568,24 @@ class FormTestCase(unittest.TestCase):
             }),
             ({('test', None): {'string': 'a'},
               ('issue', '-1'): {'nosy': ['1']},
-              ('issue', '-2'): {}
              },
              [('issue', '-2', 'superseder', [('issue', '-1')])
              ]
             )
         )
 
+    def testMessages(self):
+        self.assertEqual(self.parseForm({
+            'msg-1@content': 'asdf',
+            'msg-2@content': 'qwer',
+            '@link@messages': 'msg-1, msg-2'}),
+            ({('test', None): {},
+              ('msg', '-2'): {'content': 'qwer'},
+              ('msg', '-1'): {'content': 'asdf'}},
+             [('test', None, 'messages', [('msg', '-1'), ('msg', '-2')])]
+            )
+        )
+
     def testLinkBadDesignator(self):
         self.assertRaises(FormError, self.parseForm,
             {'test-1@link@link': 'blah'})
@@ -533,6 +610,42 @@ class FormTestCase(unittest.TestCase):
             'name': 'foo.txt', 'type': 'text/plain'}},
             [('issue', None, 'files', [('file', '-1')])]))
 
+    #
+    # SECURITY
+    #
+    # XXX test all default permissions
+    def _make_client(self, form, classname='user', nodeid='2', userid='2'):
+        cl = client.Client(self.instance, None, {'PATH_INFO':'/'},
+            makeForm(form))
+        cl.classname = 'user'
+        cl.nodeid = '1'
+        cl.db = self.db
+        cl.userid = '2'
+        cl.language = ('en',)
+        return cl
+
+    def testClassPermission(self):
+        cl = self._make_client(dict(username='bob'))
+        self.failUnlessRaises(exceptions.Unauthorised,
+            actions.EditItemAction(cl).handle)
+        cl.nodeid = '1'
+        self.assertRaises(exceptions.Unauthorised,
+            actions.EditItemAction(cl).handle)
+
+    def testCheckAndPropertyPermission(self):
+        self.db.security.permissions = {}
+        def own_record(db, userid, itemid): return userid == itemid
+        p = self.db.security.addPermission(name='Edit', klass='user',
+            check=own_record, properties=("password", ))
+        self.db.security.addPermissionToRole('User', p)
+
+        cl = self._make_client(dict(username='bob'))
+        self.assertRaises(exceptions.Unauthorised,
+            actions.EditItemAction(cl).handle)
+        cl = self._make_client({'password':'bob', '@confirm@password':'bob'})
+        self.failUnlessRaises(exceptions.Unauthorised,
+            actions.EditItemAction(cl).handle)
+
 def test_suite():
     suite = unittest.TestSuite()
     suite.addTest(unittest.makeSuite(FormTestCase))
@@ -543,4 +656,4 @@ if __name__ == '__main__':
     runner = unittest.TextTestRunner()
     unittest.main(testRunner=runner)
 
-# vim: set filetype=python ts=4 sw=4 et si
+# vim: set filetype=python sts=4 sw=4 et si :
index f213360b75e8d6bb5d9e9d43832e9b665b32dec8..a8c79ea97fe5295a21420016b72481dd730d4087 100644 (file)
 # 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.31 2003-12-04 23:06:53 richard Exp $
+#
+# $Id: test_dates.py,v 1.45 2008-03-07 01:11:55 richard Exp $
 from __future__ import nested_scopes
 
-import unittest, time
+import unittest
+import time
+import datetime
+import calendar
+
+from roundup.date import Date, Interval, Range, fixTimeOverflow, \
+    get_timezone
 
-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")
@@ -59,9 +65,17 @@ class DateTestCase(unittest.TestCase):
         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')
+        ae(str(Date('1900-02-01')), '1900-02-01.00:00:00')
+        ae(str(Date('1800-07-15')), '1800-07-15.00:00:00')
+
+    def testLeapYear(self):
+        self.assertEquals(str(Date('2008-02-29')), '2008-02-29.00:00:00')
 
     def testDateError(self):
         self.assertRaises(ValueError, Date, "12")
+        # Date cannot handle dates before year 1
+        self.assertRaises(ValueError, Date, (0, 1, 1, 0, 0, 0.0, 0, 1, -1))
+        self.assertRaises(ValueError, Date, "1/1/06")
 
     def testOffset(self):
         ae = self.assertEqual
@@ -81,6 +95,12 @@ class DateTestCase(unittest.TestCase):
         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
@@ -119,6 +139,8 @@ class DateTestCase(unittest.TestCase):
         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')
+        date = Date('2001-03-01.00:00:00') + Interval('150y')
+        ae(str(date), '2151-03-01.00:00:00')
 
     def testOffsetSub(self):
         ae = self.assertEqual
@@ -153,6 +175,8 @@ class DateTestCase(unittest.TestCase):
         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')
+        date = Date('2001-03-01.00:00:00') - Interval('150y')
+        ae(str(date), '1851-03-01.00:00:00')
 
     def testDateLocal(self):
         ae = self.assertEqual
@@ -170,6 +194,7 @@ class DateTestCase(unittest.TestCase):
         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
@@ -259,20 +284,24 @@ class DateTestCase(unittest.TestCase):
         # 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'))
+        i = Date('1952-01-01') - Date('1953-01-01')
+        self.assertEqual(i, Interval('-366d'))
+        i = Date('1953-01-01') - Date('1952-01-01')
+        self.assertEqual(i, Interval('366d'))
 
     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')), '')
-        ae(str(Interval('- 1y') + Interval('1y')), '')
+        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')), '')
-        ae(str(Interval('- 1:50') + Interval('1:50')), '')
+        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')
@@ -282,11 +311,11 @@ class DateTestCase(unittest.TestCase):
         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')), '')
+        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')), '')
+        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')
 
@@ -336,9 +365,10 @@ class DateTestCase(unittest.TestCase):
         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(Date('2003-12', add_granularity=1)), '2003-12-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)
@@ -361,7 +391,7 @@ class DateTestCase(unittest.TestCase):
         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('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')
@@ -372,13 +402,107 @@ class DateTestCase(unittest.TestCase):
         ae('-1y', '1 year ago')
         ae('-2y', '2 years ago')
 
+    def testPyDatetime(self):
+        d = datetime.datetime.now()
+        Date(d)
+        toomuch = datetime.MAXYEAR + 1
+        self.assertRaises(ValueError, Date, (toomuch, 1, 1, 0, 0, 0, 0, 1, -1))
+
+    def testSimpleTZ(self):
+        ae = self.assertEqual
+        # local to utc
+        date = Date('2006-04-04.12:00:00', 2)
+        ae(str(date), '2006-04-04.10:00:00')
+        # utc to local
+        date = Date('2006-04-04.10:00:00')
+        date = date.local(2)
+        ae(str(date), '2006-04-04.12:00:00')
+        # from Date instance
+        date = Date('2006-04-04.12:00:00')
+        date = Date(date, 2)
+        ae(str(date), '2006-04-04.10:00:00')
+
+    def testTimestamp(self):
+        ae = self.assertEqual
+        date = Date('2038')
+        ae(date.timestamp(), 2145916800)
+        date = Date('1902')
+        ae(date.timestamp(), -2145916800)
+        date = Date(time.gmtime(0))
+        ae(date.timestamp(), 0)
+        ae(str(date), '1970-01-01.00:00:00')
+        date = Date(time.gmtime(0x7FFFFFFF))
+        ae(date.timestamp(), 2147483647)
+        ae(str(date), '2038-01-19.03:14:07')
+        date = Date('1901-12-13.20:45:52')
+        ae(date.timestamp(), -0x80000000L)
+        ae(str(date), '1901-12-13.20:45:52')
+        date = Date('9999')
+        ae (date.timestamp(), 253370764800.0)
+        date = Date('0033')
+        ae (date.timestamp(), -61125753600.0)
+        ae(str(date), '0033-01-01.00:00:00')
+
+class TimezoneTestCase(unittest.TestCase):
+
+    def testTZ(self):
+        ae = self.assertEqual
+        tz = 'Europe/Warsaw'
+
+        # local to utc, DST
+        date = Date('2006-04-04.12:00:00', tz)
+        ae(str(date), '2006-04-04.10:00:00')
+
+        # local to utc, no DST
+        date = Date('2006-01-01.12:00:00', tz)
+        ae(str(date), '2006-01-01.11:00:00')
+
+        # utc to local, DST
+        date = Date('2006-04-04.10:00:00')
+        date = date.local(tz)
+        ae(str(date), '2006-04-04.12:00:00')
+
+        # utc to local, no DST
+        date = Date('2006-01-01.10:00:00')
+        date = date.local(tz)
+        ae(str(date), '2006-01-01.11:00:00')
+
+        date = Date('2006-04-04.12:00:00')
+        date = Date(date, tz)
+        ae(str(date), '2006-04-04.10:00:00')
+        date = Date('2006-01-01.12:00:00')
+        date = Date(date, tz)
+        ae(str(date), '2006-01-01.11:00:00')
+
+
+class RangeTestCase(unittest.TestCase):
+
+    def testRange(self):
+        ae = self.assertEqual
+        r = Range('2006', Date)
+        ae(str(r.from_value), '2006-01-01.00:00:00')
+        ae(str(r.to_value), '2006-12-31.23:59:59')
+        for i in range(1, 13):
+            r = Range('2006-%02d'%i, Date)
+            ae(str(r.from_value), '2006-%02d-01.00:00:00'%i)
+            ae(str(r.to_value), '2006-%02d-%02d.23:59:59'%(i,
+                calendar.mdays[i]))
+
+
 def test_suite():
     suite = unittest.TestSuite()
     suite.addTest(unittest.makeSuite(DateTestCase))
+    suite.addTest(unittest.makeSuite(RangeTestCase))
+    try:
+        import pytz
+    except ImportError:
+        pass
+    else:
+        suite.addTest(unittest.makeSuite(TimezoneTestCase))
     return suite
 
 if __name__ == '__main__':
     runner = unittest.TextTestRunner()
     unittest.main(testRunner=runner)
 
-# vim: set filetype=python ts=4 sw=4 et si
+# vim: set filetype=python sts=4 sw=4 et si :
index b99f982103a8fde16e9b799fceb751e75d370624..9d01d4df681b685f25eda4caad8e3acbde7183ad 100644 (file)
@@ -8,7 +8,7 @@
 # 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 $
+# $Id: test_hyperdbvals.py,v 1.3 2006-08-18 01:26:19 richard Exp $
 
 import unittest, os, shutil, errno, sys, difflib, cgi, re, sha
 
@@ -59,16 +59,20 @@ class RawToHyperdbTest(unittest.TestCase):
         return hyperdb.rawToHyperdb(TestDatabase(), TestClass(), itemid,
             propname, value)
     def testString(self):
+        self.assertEqual(self._test('password', ''), None)
         self.assertEqual(self._test('string', '  a string '), 'a string')
     def testNumber(self):
+        self.assertEqual(self._test('password', ''), None)
         self.assertEqual(self._test('number', '  10 '), 10)
         self.assertEqual(self._test('number', '  1.5 '), 1.5)
     def testBoolean(self):
+        self.assertEqual(self._test('password', ''), None)
         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', ''), None)
         self.assertEqual(self._test('password', '  a string '), 'a string')
         val = self._test('password', '  a string ')
         self.assert_(isinstance(val, password.Password))
@@ -83,6 +87,7 @@ class RawToHyperdbTest(unittest.TestCase):
         self.assertRaises(hyperdb.HyperdbValueError, self._test,
             'password', '{fubar}a string')
     def testDate(self):
+        self.assertEqual(self._test('password', ''), None)
         val = self._test('date', ' 2003-01-01  ')
         self.assert_(isinstance(val, date.Date))
         val = self._test('date', ' 2003/01/01  ')
@@ -94,11 +99,13 @@ class RawToHyperdbTest(unittest.TestCase):
         self.assertRaises(hyperdb.HyperdbValueError, self._test, 'date',
             'fubar')
     def testInterval(self):
+        self.assertEqual(self._test('password', ''), None)
         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('password', ''), None)
         self.assertEqual(self._test('link', '1'), '1')
         self.assertEqual(self._test('link', 'valid'), '1')
         self.assertRaises(hyperdb.HyperdbValueError, self._test, 'link',
index 8155577fc6c2780d92563adac9486c3637e8df19..4a3369e7fa4c9f154d1d8167f816370ea3ff618d 100644 (file)
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # SOFTWARE.
 
-# $Id: test_indexer.py,v 1.4 2004-03-19 04:47:59 richard Exp $
+# $Id: test_indexer.py,v 1.13 2008-09-11 19:10:30 schlatterbeck Exp $
 
 import os, unittest, shutil
 
-from roundup.backends.indexer_dbm import Indexer
+from roundup.backends import get_backend, have_backend
+from roundup.backends.indexer_rdbms import Indexer
+
+# borrow from other tests
+from db_test_base import setupSchema, config
+from test_postgresql import postgresqlOpener
+from test_mysql import mysqlOpener
+from test_sqlite import sqliteOpener
+
+class db:
+    class config(dict):
+        DATABASE = 'test-index'
+    config = config()
+    config[('main', 'indexer_stopwords')] = []
 
 class IndexerTest(unittest.TestCase):
     def setUp(self):
@@ -30,28 +43,126 @@ class IndexerTest(unittest.TestCase):
             shutil.rmtree('test-index')
         os.mkdir('test-index')
         os.mkdir('test-index/files')
-        self.dex = Indexer('test-index')
+        from roundup.backends.indexer_dbm import Indexer
+        self.dex = Indexer(db)
         self.dex.load_index()
 
+    def assertSeqEqual(self, s1, s2):
+        # first argument is the db result we're testing, second is the
+        # desired result some db results don't have iterable rows, so we
+        # have to work around that
+        # Also work around some dbs not returning items in the expected
+        # order. This would be *so* much easier with python2.4's sorted.
+        s1 = list(s1)
+        s1.sort()
+        if [i for x,y in zip(s1, s2) for i,j in enumerate(y) if x[i] != j]:
+            self.fail('contents of %r != %r'%(s1, s2))
+
     def test_basics(self):
-        self.dex.add_text('testing1', 'a the hello world')
-        self.assertEqual(self.dex.words, {'HELLO': {1: 1}, 'THE': {1: 1},
-            'WORLD': {1: 1}})
-        self.dex.add_text('testing2', 'blah blah the world')
-        self.assertEqual(self.dex.words, {'BLAH': {2: 2}, 'HELLO': {1: 1},
-            'THE': {2: 1, 1: 1}, 'WORLD': {2: 1, 1: 1}})
-        self.assertEqual(self.dex.find(['world']), {2: 'testing2',
-            1: 'testing1'})
-        self.assertEqual(self.dex.find(['blah']), {2: 'testing2'})
-        self.assertEqual(self.dex.find(['blah', 'hello']), {})
-        self.dex.save_index()
+        self.dex.add_text(('test', '1', 'foo'), 'a the hello world')
+        self.dex.add_text(('test', '2', 'foo'), 'blah blah the world')
+        self.assertSeqEqual(self.dex.find(['world']), [('test', '1', 'foo'),
+                                                    ('test', '2', 'foo')])
+        self.assertSeqEqual(self.dex.find(['blah']), [('test', '2', 'foo')])
+        self.assertSeqEqual(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.assertSeqEqual(self.dex.find(['world']), [('test', '1', 'foo'),
+                                                    ('test', '2', 'foo')])
+        self.dex.add_text(('test', '1', 'foo'), 'a the hello')
+        self.assertSeqEqual(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.assertSeqEqual(self.dex.find(['world']), [('test', '1', 'foo'),
+                                                    ('test', '2', 'foo')])
+        self.dex.add_text(('test', '1', 'foo'), '')
+        self.assertSeqEqual(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')
 
+class RDBMSIndexerTest(IndexerTest):
+    def setUp(self):
+        # remove previous test, ignore errors
+        if os.path.exists(config.DATABASE):
+            shutil.rmtree(config.DATABASE)
+        self.db = self.module.Database(config, 'admin')
+        self.dex = Indexer(self.db)
+    def tearDown(self):
+        if hasattr(self, 'db'):
+            self.db.close()
+        if os.path.exists(config.DATABASE):
+            shutil.rmtree(config.DATABASE)
+
+class postgresqlIndexerTest(postgresqlOpener, RDBMSIndexerTest):
+    def setUp(self):
+        postgresqlOpener.setUp(self)
+        RDBMSIndexerTest.setUp(self)
+    def tearDown(self):
+        RDBMSIndexerTest.tearDown(self)
+        postgresqlOpener.tearDown(self)
+
+class mysqlIndexerTest(mysqlOpener, RDBMSIndexerTest):
+    def setUp(self):
+        mysqlOpener.setUp(self)
+        RDBMSIndexerTest.setUp(self)
+    def tearDown(self):
+        RDBMSIndexerTest.tearDown(self)
+        mysqlOpener.tearDown(self)
+
+class sqliteIndexerTest(sqliteOpener, RDBMSIndexerTest):
+    pass
+
 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
+
+    if have_backend('postgresql'):
+        # make sure we start with a clean slate
+        if postgresqlOpener.module.db_exists(config):
+            postgresqlOpener.module.db_nuke(config, 1)
+        suite.addTest(unittest.makeSuite(postgresqlIndexerTest))
+    else:
+        print "Skipping postgresql indexer tests"
+
+    if have_backend('mysql'):
+        # make sure we start with a clean slate
+        if mysqlOpener.module.db_exists(config):
+            mysqlOpener.module.db_nuke(config)
+        suite.addTest(unittest.makeSuite(mysqlIndexerTest))
+    else:
+        print "Skipping mysql indexer tests"
+
+    if have_backend('sqlite'):
+        # make sure we start with a clean slate
+        if sqliteOpener.module.db_exists(config):
+            sqliteOpener.module.db_nuke(config)
+        suite.addTest(unittest.makeSuite(sqliteIndexerTest))
+    else:
+        print "Skipping sqlite indexer tests"
+
     return suite
 
 if __name__ == '__main__':
index 7599aba9b6e2c4ee3c966ccba499c80648ba8d95..0a69ad6da0042affa97c204ade518e76e5a46103 100644 (file)
@@ -8,9 +8,11 @@
 # but WITHOUT ANY WARRANTY; without even the implied warranty of
 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 #
-# $Id: test_mailgw.py,v 1.67 2004-04-09 01:32:58 richard Exp $
+# $Id: test_mailgw.py,v 1.96 2008-08-19 01:40:59 richard Exp $
 
-import unittest, tempfile, os, shutil, errno, imp, sys, difflib, rfc822
+# TODO: test bcc
+
+import unittest, tempfile, os, shutil, errno, imp, sys, difflib, rfc822, time
 
 from cStringIO import StringIO
 
@@ -19,23 +21,29 @@ if not os.environ.has_key('SENDMAILDEBUG'):
 SENDMAILDEBUG = os.environ['SENDMAILDEBUG']
 
 from roundup.mailgw import MailGW, Unauthorized, uidFromAddress, \
-    parseContent, IgnoreLoop, IgnoreBulk
-from roundup import init, instance, rfc2822, __version__
+    parseContent, IgnoreLoop, IgnoreBulk, MailUsageError, MailUsageHelp
+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()) 
+                self.fp.read() == other.fp.read())
 
 class DiffHelper:
     def compareMessages(self, new, old):
         """Compare messages for semantic equivalence."""
         new, old = Message(new), Message(old)
+
+        # all Roundup-generated messages have "Precedence: bulk"
+        old['Precedence'] = 'bulk'
+
+        # don't try to compare the date
         del new['date'], old['date']
 
         if not new == old:
@@ -45,10 +53,11 @@ class DiffHelper:
                 if key.lower() == 'x-roundup-version':
                     # version changes constantly, so handle it specially
                     if new[key] != __version__:
-                        res.append('  %s: %s != %s' % (key, __version__,
+                        res.append('  %s: %r != %r' % (key, __version__,
                             new[key]))
-                elif new[key] != old[key]:
-                    res.append('  %s: %s != %s' % (key, old[key], new[key]))
+                elif new.get(key, '') != old.get(key, ''):
+                    res.append('  %s: %r != %r' % (key, old.get(key, ''),
+                        new.get(key, '')))
 
             body_diff = self.compareStrings(new.fp.read(), old.fp.read())
             if body_diff:
@@ -58,7 +67,7 @@ class DiffHelper:
             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,
@@ -93,28 +102,19 @@ class MailgwTestCase(unittest.TestCase, DiffHelper):
     def setUp(self):
         MailgwTestCase.count = MailgwTestCase.count + 1
         self.dirname = '_test_mailgw_%s'%self.count
-        try:
-            shutil.rmtree(self.dirname)
-        except OSError, error:
-            if error.errno not in (errno.ENOENT, errno.ESRCH): raise
-        # create the instance
-        init.install(self.dirname, 'templates/classic')
-        init.write_select_db(self.dirname, 'sqlite')
-        init.initialise(self.dirname, 'sekrit')
-
-        # check we can load the package
-        self.instance = instance.open(self.dirname)
+        # 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@bork.bork.bork', realname='Bork, Chef', roles='User')
         self.richard_id = self.db.user.create(username='richard',
-            address='richard@test', roles='User')
-        self.mary_id = self.db.user.create(username='mary', address='mary@test',
+            address='richard@test.test', roles='User')
+        self.mary_id = self.db.user.create(username='mary', address='mary@test.test',
             roles='User', realname='Contrary, Mary')
-        self.john_id = self.db.user.create(username='john', address='john@test',
-            alternate_addresses='jondoe@test\njohn.doe@test', roles='User',
+        self.john_id = self.db.user.create(username='john', address='john@test.test',
+            alternate_addresses='jondoe@test.test\njohn.doe@test.test', roles='User',
             realname='John Doe')
 
     def tearDown(self):
@@ -133,7 +133,7 @@ class MailgwTestCase(unittest.TestCase, DiffHelper):
         # 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:
@@ -146,7 +146,7 @@ class MailgwTestCase(unittest.TestCase, DiffHelper):
   charset="iso-8859-1"
 From: Chef <chef@bork.bork.bork>
 To: issue_tracker@your.tracker.email.domain.example
-Cc: richard@test
+Cc: richard@test.test
 Reply-To: chef@bork.bork.bork
 Message-Id: <dummy_test_message_id>
 Subject: [issue] Testing...
@@ -160,7 +160,7 @@ Subject: [issue] Testing...
   charset="iso-8859-1"
 From: Chef <chef@bork.bork.bork>
 To: issue_tracker@your.tracker.email.domain.example
-Cc: richard@test
+Cc: richard@test.test
 Message-Id: <dummy_test_message_id>
 Subject: [issue] Testing...
 
@@ -181,7 +181,7 @@ This is a test submission of a new issue.
   charset="iso-8859-1"
 From: Chef <chef@bork.bork.bork>
 To: issue_tracker@your.tracker.email.domain.example
-Cc: richard@test
+Cc: richard@test.test
 Message-Id: <dummy_test_message_id>
 Subject: [issue] Testing...
 
@@ -195,14 +195,14 @@ This is a test submission of a new issue.
     def testAlternateAddress(self):
         self._handle_mail('''Content-Type: text/plain;
   charset="iso-8859-1"
-From: John Doe <john.doe@test>
+From: John Doe <john.doe@test.test>
 To: issue_tracker@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()        
+        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")
@@ -212,7 +212,7 @@ This is a test submission of a new issue.
   charset="iso-8859-1"
 From: Chef <chef@bork.bork.bork>
 To: issue_tracker@your.tracker.email.domain.example
-Cc: richard@test
+Cc: richard@test.test
 Message-Id: <dummy_test_message_id>
 Subject: Testing...
 
@@ -234,16 +234,17 @@ This is a test submission of a new issue.
 ''')
         self.compareMessages(self._get_mail(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
-TO: chef@bork.bork.bork, mary@test, richard@test
+TO: chef@bork.bork.bork, mary@test.test, richard@test.test
 Content-Type: text/plain; charset=utf-8
 Subject: [issue1] Testing...
-To: chef@bork.bork.bork, mary@test, richard@test
+To: chef@bork.bork.bork, mary@test.test, richard@test.test
 From: "Bork, Chef" <issue_tracker@your.tracker.email.domain.example>
 Reply-To: Roundup issue tracker <issue_tracker@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
+X-Roundup-Issue-Status: unread
 Content-Transfer-Encoding: quoted-printable
 
 
@@ -264,20 +265,194 @@ Roundup issue tracker <issue_tracker@your.tracker.email.domain.example>
 _______________________________________________________________________
 ''')
 
-    # 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. 
+    def testNewIssueNoAuthorInfo(self):
+        self.db.config.MAIL_ADD_AUTHORINFO = 'no'
+        self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef@bork.bork.bork>
+To: issue_tracker@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@your.tracker.email.domain.example
+TO: chef@bork.bork.bork, mary@test.test, richard@test.test
+Content-Type: text/plain; charset=utf-8
+Subject: [issue1] Testing...
+To: mary@test.test, richard@test.test
+From: "Bork, Chef" <issue_tracker@your.tracker.email.domain.example>
+Reply-To: Roundup issue tracker <issue_tracker@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
+X-Roundup-Issue-Status: unread
+Content-Transfer-Encoding: quoted-printable
+
+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@your.tracker.email.domain.example>
+<http://tracker.example/cgi-bin/roundup.cgi/bugs/issue1>
+_______________________________________________________________________
+''')
+
+    def testNewIssueNoAuthorEmail(self):
+        self.db.config.MAIL_ADD_AUTHOREMAIL = 'no'
+        self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef@bork.bork.bork>
+To: issue_tracker@your.tracker.email.domain.example
+Message-Id: <dummy_test_message_id>
+Subject: [issue] Testing... [nosy=mary; assignedto=richard]
 
-    # BUG should test some binary attamchent too.
+This is a test submission of a new issue.
+''')
+        self.compareMessages(self._get_mail(),
+'''FROM: roundup-admin@your.tracker.email.domain.example
+TO: chef@bork.bork.bork, mary@test.test, richard@test.test
+Content-Type: text/plain; charset=utf-8
+Subject: [issue1] Testing...
+To: mary@test.test, richard@test.test
+From: "Bork, Chef" <issue_tracker@your.tracker.email.domain.example>
+Reply-To: Roundup issue tracker <issue_tracker@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
+X-Roundup-Issue-Status: unread
+Content-Transfer-Encoding: quoted-printable
+
+New submission from Bork, Chef:
+
+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@your.tracker.email.domain.example>
+<http://tracker.example/cgi-bin/roundup.cgi/bugs/issue1>
+_______________________________________________________________________
+''')
+
+    multipart_msg = '''From: mary <mary@test.test>
+To: issue_tracker@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="bxyzzy"
+Content-Disposition: inline
+
+
+--bxyzzy
+Content-Type: multipart/alternative; boundary="bCsyhTFzCvuiizWE"
+Content-Disposition: inline
+
+--bCsyhTFzCvuiizWE
+Content-Type: text/plain; charset=us-ascii
+Content-Disposition: inline
+
+test attachment first text/plain
+
+--bCsyhTFzCvuiizWE
+Content-Type: application/octet-stream
+Content-Disposition: attachment; filename="first.dvi"
+Content-Transfer-Encoding: base64
+
+SnVzdCBhIHRlc3QgAQo=
+
+--bCsyhTFzCvuiizWE
+Content-Type: text/plain; charset=us-ascii
+Content-Disposition: inline
+
+test attachment second text/plain
+
+--bCsyhTFzCvuiizWE
+Content-Type: text/html
+Content-Disposition: inline
+
+<html>
+to be ignored.
+</html>
+
+--bCsyhTFzCvuiizWE--
+
+--bxyzzy
+Content-Type: multipart/alternative; boundary="bCsyhTFzCvuiizWF"
+Content-Disposition: inline
+
+--bCsyhTFzCvuiizWF
+Content-Type: text/plain; charset=us-ascii
+Content-Disposition: inline
+
+test attachment third text/plain
+
+--bCsyhTFzCvuiizWF
+Content-Type: application/octet-stream
+Content-Disposition: attachment; filename="second.dvi"
+Content-Transfer-Encoding: base64
+
+SnVzdCBhIHRlc3QK
+
+--bCsyhTFzCvuiizWF--
+
+--bxyzzy--
+'''
+
+    def testMultipartKeepAlternatives(self):
+        self.doNewIssue()
+        self._handle_mail(self.multipart_msg)
+        messages = self.db.issue.get('1', 'messages')
+        messages.sort()
+        msg = self.db.msg.getnode (messages[-1])
+        assert(len(msg.files) == 5)
+        names = {0 : 'first.dvi', 4 : 'second.dvi'}
+        content = {3 : 'test attachment third text/plain\n',
+                   4 : 'Just a test\n'}
+        for n, id in enumerate (msg.files):
+            f = self.db.file.getnode (id)
+            self.assertEqual(f.name, names.get (n, 'unnamed'))
+            if n in content :
+                self.assertEqual(f.content, content [n])
+        self.assertEqual(msg.content, 'test attachment second text/plain')
+
+    def testMultipartDropAlternatives(self):
+        self.doNewIssue()
+        self.db.config.MAILGW_IGNORE_ALTERNATIVES = True
+        self._handle_mail(self.multipart_msg)
+        messages = self.db.issue.get('1', 'messages')
+        messages.sort()
+        msg = self.db.msg.getnode (messages[-1])
+        assert(len(msg.files) == 2)
+        names = {1 : 'second.dvi'}
+        content = {0 : 'test attachment third text/plain\n',
+                   1 : 'Just a test\n'}
+        for n, id in enumerate (msg.files):
+            f = self.db.file.getnode (id)
+            self.assertEqual(f.name, names.get (n, 'unnamed'))
+            if n in content :
+                self.assertEqual(f.content, content [n])
+        self.assertEqual(msg.content, 'test attachment second text/plain')
 
     def testSimpleFollowup(self):
         self.doNewIssue()
         self._handle_mail('''Content-Type: text/plain;
   charset="iso-8859-1"
-From: mary <mary@test>
+From: mary <mary@test.test>
 To: issue_tracker@your.tracker.email.domain.example
 Message-Id: <followup_dummy_id>
 In-Reply-To: <dummy_test_message_id>
@@ -287,10 +462,10 @@ This is a second followup
 ''')
         self.compareMessages(self._get_mail(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
-TO: chef@bork.bork.bork, richard@test
+TO: chef@bork.bork.bork, richard@test.test
 Content-Type: text/plain; charset=utf-8
 Subject: [issue1] Testing...
-To: chef@bork.bork.bork, richard@test
+To: chef@bork.bork.bork, richard@test.test
 From: "Contrary, Mary" <issue_tracker@your.tracker.email.domain.example>
 Reply-To: Roundup issue tracker <issue_tracker@your.tracker.email.domain.example>
 MIME-Version: 1.0
@@ -298,10 +473,11 @@ Message-Id: <followup_dummy_id>
 In-Reply-To: <dummy_test_message_id>
 X-Roundup-Name: Roundup issue tracker
 X-Roundup-Loop: hello
+X-Roundup-Issue-Status: chatting
 Content-Transfer-Encoding: quoted-printable
 
 
-Contrary, Mary <mary@test> added the comment:
+Contrary, Mary <mary@test.test> added the comment:
 
 This is a second followup
 
@@ -319,7 +495,7 @@ _______________________________________________________________________
 
         self._handle_mail('''Content-Type: text/plain;
   charset="iso-8859-1"
-From: richard <richard@test>
+From: richard <richard@test.test>
 To: issue_tracker@your.tracker.email.domain.example
 Message-Id: <followup_dummy_id>
 In-Reply-To: <dummy_test_message_id>
@@ -334,10 +510,10 @@ This is a followup
 
         self.compareMessages(self._get_mail(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
-TO: chef@bork.bork.bork, john@test, mary@test
+TO: chef@bork.bork.bork, john@test.test, mary@test.test
 Content-Type: text/plain; charset=utf-8
 Subject: [issue1] Testing...
-To: chef@bork.bork.bork, john@test, mary@test
+To: chef@bork.bork.bork, john@test.test, mary@test.test
 From: richard <issue_tracker@your.tracker.email.domain.example>
 Reply-To: Roundup issue tracker <issue_tracker@your.tracker.email.domain.example>
 MIME-Version: 1.0
@@ -345,10 +521,11 @@ Message-Id: <followup_dummy_id>
 In-Reply-To: <dummy_test_message_id>
 X-Roundup-Name: Roundup issue tracker
 X-Roundup-Loop: hello
+X-Roundup-Issue-Status: chatting
 Content-Transfer-Encoding: quoted-printable
 
 
-richard <richard@test> added the comment:
+richard <richard@test.test> added the comment:
 
 This is a followup
 
@@ -363,24 +540,71 @@ Roundup issue tracker <issue_tracker@your.tracker.email.domain.example>
 _______________________________________________________________________
 ''')
 
+    def testPropertyChangeOnly(self):
+        self.doNewIssue()
+        oldvalues = self.db.getnode('issue', '1').copy()
+        oldvalues['assignedto'] = None
+        self.db.issue.set('1', assignedto=self.chef_id)
+        self.db.commit()
+        self.db.issue.nosymessage('1', None, oldvalues)
+
+        new_mail = ""
+        for line in self._get_mail().split("\n"):
+            if "Message-Id: " in line:
+                continue
+            if "Date: " in line:
+                continue
+            new_mail += line+"\n"
+
+        self.compareMessages(new_mail, """
+FROM: roundup-admin@your.tracker.email.domain.example
+TO: chef@bork.bork.bork, richard@test.test
+Content-Type: text/plain; charset=utf-8
+Subject: [issue1] Testing...
+To: chef@bork.bork.bork, richard@test.test
+From: "Bork, Chef" <issue_tracker@your.tracker.email.domain.example>
+X-Roundup-Name: Roundup issue tracker
+X-Roundup-Loop: hello
+X-Roundup-Issue-Status: unread
+X-Roundup-Version: 1.3.3
+MIME-Version: 1.0
+Reply-To: Roundup issue tracker <issue_tracker@your.tracker.email.domain.example>
+Content-Transfer-Encoding: quoted-printable
+
+
+Change by Bork, Chef <chef@bork.bork.bork>:
+
+
+----------
+assignedto:  -> Chef
+
+_______________________________________________________________________
+Roundup issue tracker <issue_tracker@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@test>
+From: richard <richard@test.test>
 To: issue_tracker@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@your.tracker.email.domain.example
-TO: chef@bork.bork.bork, john@test, mary@test
+TO: chef@bork.bork.bork, john@test.test, mary@test.test
 Content-Type: text/plain; charset=utf-8
 Subject: [issue1] Testing...
-To: chef@bork.bork.bork, john@test, mary@test
+To: chef@bork.bork.bork, john@test.test, mary@test.test
 From: richard <issue_tracker@your.tracker.email.domain.example>
 Reply-To: Roundup issue tracker <issue_tracker@your.tracker.email.domain.example>
 MIME-Version: 1.0
@@ -388,10 +612,11 @@ Message-Id: <followup_dummy_id>
 In-Reply-To: <dummy_test_message_id>
 X-Roundup-Name: Roundup issue tracker
 X-Roundup-Loop: hello
+X-Roundup-Issue-Status: chatting
 Content-Transfer-Encoding: quoted-printable
 
 
-richard <richard@test> added the comment:
+richard <richard@test.test> added the comment:
 
 This is a followup
 
@@ -406,12 +631,79 @@ Roundup issue tracker <issue_tracker@your.tracker.email.domain.example>
 _______________________________________________________________________
 ''')
 
+    def testFollowupTitleMatchMultiRe(self):
+        nodeid1 = self.doNewIssue()
+        nodeid2 = self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: richard <richard@test.test>
+To: issue_tracker@your.tracker.email.domain.example
+Message-Id: <followup_dummy_id>
+Subject: Re: Testing... [assignedto=mary; nosy=+john]
+
+This is a followup
+''')
+
+        nodeid3 = self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: richard <richard@test.test>
+To: issue_tracker@your.tracker.email.domain.example
+Message-Id: <followup2_dummy_id>
+Subject: Ang: Re: Testing...
+
+This is a followup
+''')
+        self.assertEqual(nodeid1, nodeid2)
+        self.assertEqual(nodeid1, nodeid3)
+
+    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@test.test>
+To: issue_tracker@your.tracker.email.domain.example
+Message-Id: <followup_dummy_id>
+Subject: Re: Testing...
+
+This is a followup
+'''), nodeid)
+
+    def testFollowupTitleMatchNeverInterval(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@test.test>
+To: issue_tracker@your.tracker.email.domain.example
+Message-Id: <followup_dummy_id>
+Subject: Re: Testing...
+
+This is a followup
+'''), nodeid)
+
+
+    def testFollowupTitleMatchInterval(self):
+        nodeid = self.doNewIssue()
+        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@test.test>
+To: issue_tracker@your.tracker.email.domain.example
+Message-Id: <followup_dummy_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@test
+From: john@test.test
 To: issue_tracker@your.tracker.email.domain.example
 Message-Id: <followup_dummy_id>
 In-Reply-To: <dummy_test_message_id>
@@ -422,10 +714,10 @@ This is a followup
 
         self.compareMessages(self._get_mail(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
-TO: chef@bork.bork.bork, richard@test
+TO: chef@bork.bork.bork, richard@test.test
 Content-Type: text/plain; charset=utf-8
 Subject: [issue1] Testing...
-To: chef@bork.bork.bork, richard@test
+To: chef@bork.bork.bork, richard@test.test
 From: John Doe <issue_tracker@your.tracker.email.domain.example>
 Reply-To: Roundup issue tracker <issue_tracker@your.tracker.email.domain.example>
 MIME-Version: 1.0
@@ -433,10 +725,11 @@ Message-Id: <followup_dummy_id>
 In-Reply-To: <dummy_test_message_id>
 X-Roundup-Name: Roundup issue tracker
 X-Roundup-Loop: hello
+X-Roundup-Issue-Status: chatting
 Content-Transfer-Encoding: quoted-printable
 
 
-John Doe <john@test> added the comment:
+John Doe <john@test.test> added the comment:
 
 This is a followup
 
@@ -456,9 +749,9 @@ _______________________________________________________________________
         self.db.config.ADD_RECIPIENTS_TO_NOSY = 'yes'
         self._handle_mail('''Content-Type: text/plain;
   charset="iso-8859-1"
-From: richard@test
+From: richard@test.test
 To: issue_tracker@your.tracker.email.domain.example
-Cc: john@test
+Cc: john@test.test
 Message-Id: <followup_dummy_id>
 In-Reply-To: <dummy_test_message_id>
 Subject: [issue1] Testing...
@@ -478,10 +771,11 @@ Message-Id: <followup_dummy_id>
 In-Reply-To: <dummy_test_message_id>
 X-Roundup-Name: Roundup issue tracker
 X-Roundup-Loop: hello
+X-Roundup-Issue-Status: chatting
 Content-Transfer-Encoding: quoted-printable
 
 
-richard <richard@test> added the comment:
+richard <richard@test.test> added the comment:
 
 This is a followup
 
@@ -502,7 +796,7 @@ _______________________________________________________________________
         self.db.config.MESSAGES_TO_AUTHOR = 'yes'
         self._handle_mail('''Content-Type: text/plain;
   charset="iso-8859-1"
-From: john@test
+From: john@test.test
 To: issue_tracker@your.tracker.email.domain.example
 Message-Id: <followup_dummy_id>
 In-Reply-To: <dummy_test_message_id>
@@ -512,10 +806,10 @@ This is a followup
 ''')
         self.compareMessages(self._get_mail(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
-TO: chef@bork.bork.bork, john@test, richard@test
+TO: chef@bork.bork.bork, john@test.test, richard@test.test
 Content-Type: text/plain; charset=utf-8
 Subject: [issue1] Testing...
-To: chef@bork.bork.bork, john@test, richard@test
+To: chef@bork.bork.bork, john@test.test, richard@test.test
 From: John Doe <issue_tracker@your.tracker.email.domain.example>
 Reply-To: Roundup issue tracker <issue_tracker@your.tracker.email.domain.example>
 MIME-Version: 1.0
@@ -523,10 +817,11 @@ Message-Id: <followup_dummy_id>
 In-Reply-To: <dummy_test_message_id>
 X-Roundup-Name: Roundup issue tracker
 X-Roundup-Loop: hello
+X-Roundup-Issue-Status: chatting
 Content-Transfer-Encoding: quoted-printable
 
 
-John Doe <john@test> added the comment:
+John Doe <john@test.test> added the comment:
 
 This is a followup
 
@@ -546,7 +841,7 @@ _______________________________________________________________________
         self.instance.config.ADD_AUTHOR_TO_NOSY = 'no'
         self._handle_mail('''Content-Type: text/plain;
   charset="iso-8859-1"
-From: john@test
+From: john@test.test
 To: issue_tracker@your.tracker.email.domain.example
 Message-Id: <followup_dummy_id>
 In-Reply-To: <dummy_test_message_id>
@@ -556,10 +851,10 @@ This is a followup
 ''')
         self.compareMessages(self._get_mail(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
-TO: chef@bork.bork.bork, richard@test
+TO: chef@bork.bork.bork, richard@test.test
 Content-Type: text/plain; charset=utf-8
 Subject: [issue1] Testing...
-To: chef@bork.bork.bork, richard@test
+To: chef@bork.bork.bork, richard@test.test
 From: John Doe <issue_tracker@your.tracker.email.domain.example>
 Reply-To: Roundup issue tracker <issue_tracker@your.tracker.email.domain.example>
 MIME-Version: 1.0
@@ -567,10 +862,11 @@ Message-Id: <followup_dummy_id>
 In-Reply-To: <dummy_test_message_id>
 X-Roundup-Name: Roundup issue tracker
 X-Roundup-Loop: hello
+X-Roundup-Issue-Status: chatting
 Content-Transfer-Encoding: quoted-printable
 
 
-John Doe <john@test> added the comment:
+John Doe <john@test.test> added the comment:
 
 This is a followup
 
@@ -589,9 +885,9 @@ _______________________________________________________________________
         self.instance.config.ADD_RECIPIENTS_TO_NOSY = 'no'
         self._handle_mail('''Content-Type: text/plain;
   charset="iso-8859-1"
-From: richard@test
+From: richard@test.test
 To: issue_tracker@your.tracker.email.domain.example
-Cc: john@test
+Cc: john@test.test
 Message-Id: <followup_dummy_id>
 In-Reply-To: <dummy_test_message_id>
 Subject: [issue1] Testing...
@@ -611,10 +907,11 @@ Message-Id: <followup_dummy_id>
 In-Reply-To: <dummy_test_message_id>
 X-Roundup-Name: Roundup issue tracker
 X-Roundup-Loop: hello
+X-Roundup-Issue-Status: chatting
 Content-Transfer-Encoding: quoted-printable
 
 
-richard <richard@test> added the comment:
+richard <richard@test.test> added the comment:
 
 This is a followup
 
@@ -633,12 +930,32 @@ _______________________________________________________________________
 
         self._handle_mail('''Content-Type: text/plain;
   charset="iso-8859-1"
-From: richard <richard@test>
+From: richard <richard@test.test>
 To: issue_tracker@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@test.test>
+To: issue_tracker@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()
@@ -653,7 +970,7 @@ Subject: [issue1] Testing... [assignedto=mary; nosy=+john]
 
         self._handle_mail('''Content-Type: text/plain;
   charset="iso-8859-1"
-From: richard <richard@test>
+From: richard <richard@test.test>
 To: issue_tracker@your.tracker.email.domain.example
 Message-Id: <followup_dummy_id>
 In-Reply-To: <dummy_test_message_id>
@@ -674,7 +991,6 @@ Subject: [issue1] Testing... [nosy=-richard]
         anonid = self.db.user.lookup('anonymous')
         self.db.user.set(anonid, roles='Anonymous')
 
-        self.db.security.hasPermission('Email Registration', anonid)
         l = self.db.user.list()
         l.sort()
         message = '''Content-Type: text/plain;
@@ -686,14 +1002,58 @@ Subject: [issue] Testing...
 
 This is a test submission of a new issue.
 '''
-        self.assertRaises(Unauthorized, self._handle_mail, message)
+        try:
+            self._handle_mail(message)
+        except Unauthorized, value:
+            body_diff = self.compareMessages(str(value), """
+You are not a registered user.
+
+Unknown address: fubar@bork.bork.bork
+""")
+
+            assert not body_diff, body_diff
+
+        else:
+            raise AssertionError, "Unathorized not raised when handling mail"
+
+        # Add Web Access role to anonymous, and try again to make sure
+        # we get a "please register at:" message this time.
+        p = [
+            self.db.security.getPermission('Create', 'user'),
+            self.db.security.getPermission('Web Access', None),
+        ]
+
+        self.db.security.role['anonymous'].permissions=p
+
+        try:
+            self._handle_mail(message)
+        except Unauthorized, value:
+            body_diff = self.compareMessages(str(value), """
+You are not a registered user. Please register at:
+
+http://tracker.example/cgi-bin/roundup.cgi/bugs/user?template=register
+
+...before sending mail to the tracker.
+
+Unknown address: fubar@bork.bork.bork
+""")
+
+            assert not body_diff, body_diff
+
+        else:
+            raise AssertionError, "Unathorized not raised when handling mail"
+
+        # Make sure list of users is the same as before.
         m = self.db.user.list()
         m.sort()
         self.assertEqual(l, m)
 
         # now with the permission
-        p = self.db.security.getPermission('Email Registration')
-        self.db.security.role['anonymous'].permissions=[p]
+        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()
@@ -703,7 +1063,7 @@ This is a test submission of a new issue.
         self.doNewIssue()
         self._handle_mail('''Content-Type: text/plain;
   charset="iso-8859-1"
-From: mary <mary@test>
+From: mary <mary@test.test>
 To: issue_tracker@your.tracker.email.domain.example
 Message-Id: <followup_dummy_id>
 In-Reply-To: <dummy_test_message_id>
@@ -717,10 +1077,10 @@ A message with encoding (encoded oe =F6)
 ''')
         self.compareMessages(self._get_mail(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
-TO: chef@bork.bork.bork, richard@test
+TO: chef@bork.bork.bork, richard@test.test
 Content-Type: text/plain; charset=utf-8
 Subject: [issue1] Testing...
-To: chef@bork.bork.bork, richard@test
+To: chef@bork.bork.bork, richard@test.test
 From: "Contrary, Mary" <issue_tracker@your.tracker.email.domain.example>
 Reply-To: Roundup issue tracker <issue_tracker@your.tracker.email.domain.example>
 MIME-Version: 1.0
@@ -728,10 +1088,11 @@ Message-Id: <followup_dummy_id>
 In-Reply-To: <dummy_test_message_id>
 X-Roundup-Name: Roundup issue tracker
 X-Roundup-Loop: hello
+X-Roundup-Issue-Status: chatting
 Content-Transfer-Encoding: quoted-printable
 
 
-Contrary, Mary <mary@test> added the comment:
+Contrary, Mary <mary@test.test> added the comment:
 
 A message with encoding (encoded oe =C3=B6)
 
@@ -749,7 +1110,7 @@ _______________________________________________________________________
         self.doNewIssue()
         self._handle_mail('''Content-Type: text/plain;
   charset="iso-8859-1"
-From: mary <mary@test>
+From: mary <mary@test.test>
 To: issue_tracker@your.tracker.email.domain.example
 Message-Id: <followup_dummy_id>
 In-Reply-To: <dummy_test_message_id>
@@ -770,10 +1131,10 @@ A message with first part encoded (encoded oe =F6)
 ''')
         self.compareMessages(self._get_mail(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
-TO: chef@bork.bork.bork, richard@test
+TO: chef@bork.bork.bork, richard@test.test
 Content-Type: text/plain; charset=utf-8
 Subject: [issue1] Testing...
-To: chef@bork.bork.bork, richard@test
+To: chef@bork.bork.bork, richard@test.test
 From: "Contrary, Mary" <issue_tracker@your.tracker.email.domain.example>
 Reply-To: Roundup issue tracker <issue_tracker@your.tracker.email.domain.example>
 MIME-Version: 1.0
@@ -781,10 +1142,11 @@ Message-Id: <followup_dummy_id>
 In-Reply-To: <dummy_test_message_id>
 X-Roundup-Name: Roundup issue tracker
 X-Roundup-Loop: hello
+X-Roundup-Issue-Status: chatting
 Content-Transfer-Encoding: quoted-printable
 
 
-Contrary, Mary <mary@test> added the comment:
+Contrary, Mary <mary@test.test> added the comment:
 
 A message with first part encoded (encoded oe =C3=B6)
 
@@ -801,40 +1163,42 @@ _______________________________________________________________________
         self.doNewIssue()
         self._handle_mail('''Content-Type: text/plain;
   charset="iso-8859-1"
-From: mary <mary@test>
+From: mary <mary@test.test>
 To: issue_tracker@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 
+Content-Type: multipart/mixed; boundary="bCsyhTFzCvuiizWE"
+Content-Disposition: inline
+
+
+--bCsyhTFzCvuiizWE
+Content-Type: text/plain; charset=us-ascii
+Content-Disposition: inline
 
-test attachment binary 
+test attachment binary
 
---bCsyhTFzCvuiizWE 
-Content-Type: application/octet-stream 
-Content-Disposition: attachment; filename="main.dvi" 
+--bCsyhTFzCvuiizWE
+Content-Type: application/octet-stream
+Content-Disposition: attachment; filename="main.dvi"
+Content-Transfer-Encoding: base64
 
-xxxxxx 
+SnVzdCBhIHRlc3QgAQo=
 
 --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')
+        file = self.db.file.getnode (self.db.msg.get(messages[-1], 'files')[0])
+        self.assertEqual(file.name, 'main.dvi')
+        self.assertEqual(file.content, 'Just a test \001\n')
 
     def testFollowupStupidQuoting(self):
         self.doNewIssue()
 
         self._handle_mail('''Content-Type: text/plain;
   charset="iso-8859-1"
-From: richard <richard@test>
+From: richard <richard@test.test>
 To: issue_tracker@your.tracker.email.domain.example
 Message-Id: <followup_dummy_id>
 In-Reply-To: <dummy_test_message_id>
@@ -855,10 +1219,11 @@ Message-Id: <followup_dummy_id>
 In-Reply-To: <dummy_test_message_id>
 X-Roundup-Name: Roundup issue tracker
 X-Roundup-Loop: hello
+X-Roundup-Issue-Status: chatting
 Content-Transfer-Encoding: quoted-printable
 
 
-richard <richard@test> added the comment:
+richard <richard@test.test> added the comment:
 
 This is a followup
 
@@ -893,7 +1258,7 @@ This is a followup
 
         self._handle_mail('''Content-Type: text/plain;
   charset="iso-8859-1"
-From: richard <richard@test>
+From: richard <richard@test.test>
 To: issue_tracker@your.tracker.email.domain.example
 Message-Id: <followup_dummy_id>
 In-Reply-To: <dummy_test_message_id>
@@ -946,9 +1311,9 @@ This is a followup
   charset="iso-8859-1"
 From: Chef <chef@bork.bork.bork>
 To: issue_tracker@your.tracker.email.domain.example
-Cc: richard@test
+Cc: richard@test.test
 Message-Id: <dummy_test_message_id>
-Subject: Re: Complete your registration to Roundup issue tracker\r
+Subject: Re: Complete your registration to Roundup issue tracker
  -- key %s
 
 This is a test confirmation of registration.
@@ -959,22 +1324,22 @@ This is a test confirmation of registration.
         self.db.keyword.create(name='Foo')
         self._handle_mail('''Content-Type: text/plain;
   charset="iso-8859-1"
-From: richard <richard@test>
+From: richard <richard@test.test>
 To: issue_tracker@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@bork.bork.bork>
-Resent-From: mary <mary@test>
+Resent-From: mary <mary@test.test>
 To: issue_tracker@your.tracker.email.domain.example
-Cc: richard@test
+Cc: richard@test.test
 Message-Id: <dummy_test_message_id>
 Subject: [issue] Testing...
 
@@ -986,7 +1351,6 @@ This is a test submission of a new issue.
         self.assertEqual(l, [self.richard_id, self.mary_id])
         return nodeid
 
-
     def testDejaVu(self):
         self.assertRaises(IgnoreLoop, self._handle_mail,
             '''Content-Type: text/plain;
@@ -994,7 +1358,7 @@ This is a test submission of a new issue.
 From: Chef <chef@bork.bork.bork>
 X-Roundup-Loop: hello
 To: issue_tracker@your.tracker.email.domain.example
-Cc: richard@test
+Cc: richard@test.test
 Message-Id: <dummy_test_message_id>
 Subject: Re: [issue] Testing...
 
@@ -1008,13 +1372,366 @@ Hi, I've been mis-configured to loop messages back to myself.
 From: Chef <chef@bork.bork.bork>
 Precedence: bulk
 To: issue_tracker@your.tracker.email.domain.example
-Cc: richard@test
+Cc: richard@test.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@bork.bork.bork>
+To: issue_tracker@your.tracker.email.domain.example
+Cc: richard@test.test
+Message-Id: <dummy_test_message_id>
+Subject: Re: [issue] Out of office AutoReply: Back next week
+
+Hi, I am 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@bork.bork.bork>
+To: issue_tracker@your.tracker.email.domain.example
+Cc: richard@test.test
+Reply-To: chef@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@bork.bork.bork>
+To: issue_tracker@your.tracker.email.domain.example
+Subject: [frobulated] testing
+Cc: richard@test.test
+Reply-To: chef@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@bork.bork.bork>
+To: issue_tracker@your.tracker.email.domain.example
+Subject: [issue12345] testing
+Cc: richard@test.test
+Reply-To: chef@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@bork.bork.bork>
+To: issue_tracker@your.tracker.email.domain.example
+Subject: [frobulated] testing
+Cc: richard@test.test
+Reply-To: chef@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 testInvalidClassLooseReply(self):
+        self.instance.config.MAILGW_SUBJECT_PREFIX_PARSING = 'loose'
+        nodeid = self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef@bork.bork.bork>
+To: issue_tracker@your.tracker.email.domain.example
+Subject: Re: [frobulated] testing
+Cc: richard@test.test
+Reply-To: chef@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@bork.bork.bork>
+To: issue_tracker@your.tracker.email.domain.example
+Subject: [issue1234] testing
+Cc: richard@test.test
+Reply-To: chef@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@bork.bork.bork>
+To: issue_tracker@your.tracker.email.domain.example
+Subject: [keyword1] Testing... [name=Bar]
+Cc: richard@test.test
+Reply-To: chef@bork.bork.bork
+Message-Id: <dummy_test_message_id>
+
+''')
+        assert not os.path.exists(SENDMAILDEBUG)
+        self.assertEqual(self.db.keyword.get('1', 'name'), 'Bar')
+
+    def testClassStrictInvalid(self):
+        self.instance.config.MAILGW_SUBJECT_PREFIX_PARSING = 'strict'
+        self.instance.config.MAILGW_DEFAULT_CLASS = ''
+
+        message = '''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef@bork.bork.bork>
+To: issue_tracker@your.tracker.email.domain.example
+Subject: Testing...
+Cc: richard@test.test
+Reply-To: chef@bork.bork.bork
+Message-Id: <dummy_test_message_id>
+
+'''
+        self.assertRaises(MailUsageError, self._handle_mail, message)
+
+    def testClassStrictValid(self):
+        self.instance.config.MAILGW_SUBJECT_PREFIX_PARSING = 'strict'
+        self.instance.config.MAILGW_DEFAULT_CLASS = ''
+
+        nodeid = self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef@bork.bork.bork>
+To: issue_tracker@your.tracker.email.domain.example
+Subject: [issue] Testing...
+Cc: richard@test.test
+Reply-To: chef@bork.bork.bork
+Message-Id: <dummy_test_message_id>
+
+''')
+
+        assert not os.path.exists(SENDMAILDEBUG)
+        self.assertEqual(self.db.issue.get(nodeid, 'title'), 'Testing...')
+
+    #
+    # 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@bork.bork.bork>
+To: issue_tracker@your.tracker.email.domain.example
+Subject: testing [frobulated]
+Cc: richard@test.test
+Reply-To: chef@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@bork.bork.bork>
+To: issue_tracker@your.tracker.email.domain.example
+Subject: testing [frobulated]
+Cc: richard@test.test
+Reply-To: chef@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@bork.bork.bork>
+To: issue_tracker@your.tracker.email.domain.example
+Subject: testing [frobulated]
+Cc: richard@test.test
+Reply-To: chef@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@bork.bork.bork>
+To: issue_tracker@your.tracker.email.domain.example
+Subject: testing [assignedto=mary]
+Cc: richard@test.test
+Reply-To: chef@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@bork.bork.bork>
+To: issue_tracker@your.tracker.email.domain.example
+Subject: testing {assignedto=mary}
+Cc: richard@test.test
+Reply-To: chef@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 testPrefixDelimiters(self):
+        self.instance.config.MAILGW_SUBJECT_SUFFIX_DELIMITERS = '{}'
+        self.db.keyword.create(name='Foo')
+        self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: richard <richard@test.test>
+To: issue_tracker@your.tracker.email.domain.example
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+Subject: {keyword1} Testing... {name=Bar}
+
+''')
+        assert not os.path.exists(SENDMAILDEBUG)
+        self.assertEqual(self.db.keyword.get('1', 'name'), 'Bar')
+
+    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@bork.bork.bork>
+To: issue_tracker@your.tracker.email.domain.example
+Subject: testing [assignedto=mary]
+Cc: richard@test.test
+Reply-To: chef@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 testReplytoMatch(self):
+        self.instance.config.MAILGW_SUBJECT_PREFIX_PARSING = 'loose'
+        nodeid = self.doNewIssue()
+        nodeid2 = self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef@bork.bork.bork>
+To: issue_tracker@your.tracker.email.domain.example
+Message-Id: <dummy_test_message_id2>
+In-Reply-To: <dummy_test_message_id>
+Subject: Testing...
+
+Followup message.
+''')
+
+        nodeid3 = self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef@bork.bork.bork>
+To: issue_tracker@your.tracker.email.domain.example
+Message-Id: <dummy_test_message_id3>
+In-Reply-To: <dummy_test_message_id2>
+Subject: Testing...
+
+Yet another message in the same thread/issue.
+''')
+
+        self.assertEqual(nodeid, nodeid2)
+        self.assertEqual(nodeid, nodeid3)
+
+    def testHelpSubject(self):
+        message = '''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef@bork.bork.bork>
+To: issue_tracker@your.tracker.email.domain.example
+Message-Id: <dummy_test_message_id2>
+In-Reply-To: <dummy_test_message_id>
+Subject: hElp
+
+
+'''
+        self.assertRaises(MailUsageHelp, self._handle_mail, message)
+
+    def testMaillistSubject(self):
+        self.instance.config.MAILGW_SUBJECT_SUFFIX_DELIMITERS = '[]'
+        self.db.keyword.create(name='Foo')
+        self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef@bork.bork.bork>
+To: issue_tracker@your.tracker.email.domain.example
+Subject: [mailinglist-name] [keyword1] Testing.. [name=Bar]
+Cc: richard@test.test
+Reply-To: chef@bork.bork.bork
+Message-Id: <dummy_test_message_id>
+
+''')
+
+        assert not os.path.exists(SENDMAILDEBUG)
+        self.assertEqual(self.db.keyword.get('1', 'name'), 'Bar')
+
+    def testUnknownPrefixSubject(self):
+        self.db.keyword.create(name='Foo')
+        self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: Chef <chef@bork.bork.bork>
+To: issue_tracker@your.tracker.email.domain.example
+Subject: VeryStrangeRe: [keyword1] Testing.. [name=Bar]
+Cc: richard@test.test
+Reply-To: chef@bork.bork.bork
+Message-Id: <dummy_test_message_id>
+
+''')
+
+        assert not os.path.exists(SENDMAILDEBUG)
+        self.assertEqual(self.db.keyword.get('1', 'name'), 'Bar')
+
+    def testIssueidLast(self):
+        nodeid1 = self.doNewIssue()
+        nodeid2 = self._handle_mail('''Content-Type: text/plain;
+  charset="iso-8859-1"
+From: mary <mary@test.test>
+To: issue_tracker@your.tracker.email.domain.example
+Message-Id: <followup_dummy_id>
+In-Reply-To: <dummy_test_message_id>
+Subject: New title [issue1]
+
+This is a second followup
+''')
+
+        assert nodeid1 == nodeid2
+        self.assertEqual(self.db.issue.get(nodeid2, 'title'), "Testing...")
+
+
 def test_suite():
     suite = unittest.TestSuite()
     suite.addTest(unittest.makeSuite(MailgwTestCase))
@@ -1024,4 +1741,4 @@ if __name__ == '__main__':
     runner = unittest.TextTestRunner()
     unittest.main(testRunner=runner)
 
-# vim: set filetype=python ts=4 sw=4 et si
+# vim: set filetype=python sts=4 sw=4 et si :
diff --git a/test/test_metakit.py b/test/test_metakit.py
deleted file mode 100644 (file)
index f4d95d6..0000000
+++ /dev/null
@@ -1,82 +0,0 @@
-#
-# 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.5 2004-03-24 05:33:13 richard Exp $ 
-import unittest, os, shutil, time, weakref
-
-from db_test_base import DBTest, ROTest, SchemaTest, ClassicInitTest, config, password
-
-from roundup import backends
-
-class metakitOpener:
-    if hasattr(backends, 'metakit'):
-        from roundup.backends import metakit as module
-        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 hasattr(backends, '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)
-
index 99e0bf40427c5eea920b0dc8c08f9ba951c862fe..5797dd21f35f53e1c2b3c52ec769a63c8806c61c 100644 (file)
@@ -15,7 +15,7 @@
 # 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 $ 
+# $Id: test_multipart.py,v 1.8 2007-09-22 07:25:35 jpend Exp $ 
 
 import unittest
 from cStringIO import StringIO
@@ -30,7 +30,7 @@ class TestMessage(Message):
              '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'.
 
@@ -44,10 +44,10 @@ class TestMessage(Message):
             content_type = line.strip()
             if not content_type:
                 continue
-            
+
             indent = self.getIndent(line)
             if indent:
-                parts.append('--boundary-%s\n' % indent)
+                parts.append('\n--boundary-%s\n' % indent)
             parts.append('Content-type: %s;\n' % content_type)
             parts.append(self.table[content_type] % {'indent': indent + 1})
 
@@ -68,7 +68,7 @@ class MultipartTestCase(unittest.TestCase):
         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('\r\n--foo\r\n')
 
         w('Content-Type: text/plain\r\n\r\n')
         w('Hello, world!\r\n')
@@ -76,26 +76,26 @@ class MultipartTestCase(unittest.TestCase):
         w('Blah blah\r\n')
         w('foo\r\n')
         w('-foo\r\n')
-        w('--foo\r\n')
+        w('\r\n--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('\r\n--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('\r\n--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('\r\n--bar--\r\n')
+        w('\r\n--foo\r\n')
 
         w('Content-Type: text/plain\r\n\r\n')
         w('Last bit\n')
-        w('--foo--\r\n')
+        w('\r\n--foo--\r\n')
         self.fp.seek(0)
 
     def testMultipart(self):
@@ -185,7 +185,7 @@ multipart/mixed
         text/plain
         application/pdf
 """, ('foo\n', [('foo.pdf', 'application/pdf', 'foo\n')]))
-    
+
     def testSignedText(self):
         self.TestExtraction("""
 multipart/signed
index e30e0b1dc86d191ceb2efd8adf361f6bf6842d50..75e862a7ef6fb08ab74a75a6332e326cb8c8f7b8 100644 (file)
 # 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.9 2004-03-24 06:18:59 richard Exp $ 
+#
+# $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 import init, backends
+from roundup.backends import get_backend, have_backend
 
 from db_test_base import DBTest, ROTest, config, SchemaTest, ClassicInitTest
 
 
-# Mysql connection data
-config.MYSQL_DBHOST = 'localhost'
-config.MYSQL_DBUSER = 'rounduptest'
-config.MYSQL_DBPASSWORD = 'rounduptest'
-config.MYSQL_DBNAME = 'rounduptest'
-config.MYSQL_DATABASE = (config.MYSQL_DBHOST, config.MYSQL_DBUSER,
-    config.MYSQL_DBPASSWORD, config.MYSQL_DBNAME)
-
 class mysqlOpener:
-    if hasattr(backends, 'mysql'):
-        from roundup.backends import mysql as module
+    if have_backend('mysql'):
+        module = get_backend('mysql')
 
     def setUp(self):
         self.module.db_nuke(config)
@@ -64,13 +56,6 @@ class mysqlSchemaTest(mysqlOpener, SchemaTest):
 
 class mysqlClassicInitTest(mysqlOpener, ClassicInitTest):
     backend = 'mysql'
-    extra_config = '''
-MYSQL_DBHOST = 'localhost'
-MYSQL_DBUSER = 'rounduptest'
-MYSQL_DBPASSWORD = 'rounduptest'
-MYSQL_DBNAME = 'rounduptest'
-MYSQL_DATABASE = (MYSQL_DBHOST, MYSQL_DBUSER, MYSQL_DBPASSWORD, MYSQL_DBNAME)
-'''
     def setUp(self):
         mysqlOpener.setUp(self)
         ClassicInitTest.setUp(self)
@@ -89,17 +74,16 @@ class mysqlSessionTest(mysqlOpener, RDBMSTest):
 
 def test_suite():
     suite = unittest.TestSuite()
-    if not hasattr(backends, 'mysql'):
+    if not have_backend('mysql'):
         print "Skipping mysql tests"
         return suite
 
-    from roundup.backends import mysql
+    import MySQLdb
     try:
-        # Check if we can run mysql tests
-        import MySQLdb
-        db = mysql.Database(config, 'admin')
-        db.close()
-    except (MySQLdb.ProgrammingError, DatabaseError), msg:
+        # 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'
@@ -114,3 +98,4 @@ if __name__ == '__main__':
     runner = unittest.TextTestRunner()
     unittest.main(testRunner=runner)
 
+# vim: set et sts=4 sw=4 :
index bec33c4758a57e7cbcd5f76e3fb2fea212d41c70..3d2629c32bf947f37d397d37444d9da830a33e8f 100644 (file)
@@ -14,8 +14,8 @@
 # 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.8 2004-03-25 02:16:08 richard Exp $ 
+#
+# $Id: test_postgresql.py,v 1.13 2006-08-23 12:57:10 schlatterbeck Exp $
 
 import unittest
 
@@ -23,19 +23,13 @@ from roundup.hyperdb import DatabaseError
 
 from db_test_base import DBTest, ROTest, config, SchemaTest, ClassicInitTest
 
-# Postgresql connection data
-# NOTE: THIS MUST BE A LOCAL DATABASE
-config.POSTGRESQL_DATABASE = {'database': 'rounduptest'}
-
-from roundup import backends
+from roundup.backends import get_backend, have_backend
 
 class postgresqlOpener:
-    if hasattr(backends, 'postgresql'):
-        from roundup.backends import postgresql as module
+    if have_backend('postgresql'):
+        module = get_backend('postgresql')
 
     def setUp(self):
-        #from roundup.backends.back_postgresql import db_nuke
-        #db_nuke(config, 1)
         pass
 
     def tearDown(self):
@@ -43,8 +37,7 @@ class postgresqlOpener:
 
     def nuke_database(self):
         # clear out the database - easiest way is to nuke and re-create it
-        from roundup.backends.back_postgresql import db_nuke
-        db_nuke(config)
+        self.module.db_nuke(config)
 
 class postgresqlDBTest(postgresqlOpener, DBTest):
     def setUp(self):
@@ -55,15 +48,6 @@ class postgresqlDBTest(postgresqlOpener, DBTest):
         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)
@@ -84,7 +68,6 @@ class postgresqlSchemaTest(postgresqlOpener, SchemaTest):
 
 class postgresqlClassicInitTest(postgresqlOpener, ClassicInitTest):
     backend = 'postgresql'
-    extra_config = "POSTGRESQL_DATABASE = {'database': 'rounduptest'}"
     def setUp(self):
         postgresqlOpener.setUp(self)
         ClassicInitTest.setUp(self)
@@ -104,13 +87,13 @@ class postgresqlSessionTest(postgresqlOpener, RDBMSTest):
 
 def test_suite():
     suite = unittest.TestSuite()
-    if not hasattr(backends, 'postgresql'):
+    if not have_backend('postgresql'):
         print "Skipping postgresql tests"
         return suite
 
     # make sure we start with a clean slate
-    from roundup.backends.back_postgresql import db_nuke
-    db_nuke(config, 1)
+    if postgresqlOpener.module.db_exists(config):
+        postgresqlOpener.module.db_nuke(config, 1)
 
     # TODO: Check if we can run postgresql tests
     print 'Including postgresql tests'
@@ -121,3 +104,4 @@ def test_suite():
     suite.addTest(unittest.makeSuite(postgresqlSessionTest))
     return suite
 
+# vim: set et sts=4 sw=4 :
diff --git a/test/test_rfc2822.py b/test/test_rfc2822.py
new file mode 100644 (file)
index 0000000..0c5e660
--- /dev/null
@@ -0,0 +1,31 @@
+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)
+
+        src = 'Was machen\xc3\xbc und Fragezeichen?'
+        result = '=?utf-8?q?Was_machen=C3=BC_und_Fragezeichen=3F?='
+        self.assertEqual(encode_header(src), result)
+
+def test_suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(RFC2822TestCase))
+    return suite
index d6c9e6455057ceeb3f8b684664af5f34499b301e..a1f7359fe38f949751cc4f870652bc624fd55f94 100644 (file)
 # 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.13 2003-10-25 22:53:26 richard Exp $ 
+#
+# $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
 
-class config:
-    DATABASE='_test_dir'
-    MAILHOST = 'localhost'
-    MAIL_DOMAIN = 'fill.me.in.'
-    NSTANCE_NAME = 'Roundup issue tracker'
-    TRACKER_EMAIL = 'issue_tracker@%s'%MAIL_DOMAIN
-    TRACKER_WEB = 'http://some.useful.url/'
-    ADMIN_EMAIL = 'roundup-admin@%s'%MAIL_DOMAIN
-    FILTER_POSITION = 'bottom'      # one of 'top', 'bottom', 'top and bottom'
-    ANONYMOUS_ACCESS = 'deny'       # either 'deny' or 'allow'
-    ANONYMOUS_REGISTER = 'deny'     # either 'deny' or 'allow'
-    MESSAGES_TO_AUTHOR = 'no'       # either 'yes' or 'no'
-    EMAIL_SIGNATURE_POSITION = 'bottom'
+config = configuration.CoreConfig()
+config.DATABASE = "_test_dir"
 
 class SchemaTestCase(unittest.TestCase):
     def setUp(self):
@@ -49,7 +39,7 @@ class SchemaTestCase(unittest.TestCase):
 
     def tearDown(self):
         self.db.close()
-        shutil.rmtree('_test_dir')
+        shutil.rmtree(config.DATABASE)
 
     def testA_Status(self):
         status = back_anydbm.Class(self.db, "status", name=String())
@@ -95,4 +85,4 @@ if __name__ == '__main__':
     unittest.main(testRunner=runner)
 
 
-# vim: set filetype=python ts=4 sw=4 et si
+# vim: set filetype=python sts=4 sw=4 et si :
index 00a8a24223094deb78fe7ff2315a77856abaa152..825b582483058d9bafd534ed91493f2c015aefb9 100644 (file)
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # SOFTWARE.
 
-# $Id: test_security.py,v 1.6 2003-10-25 22:53:26 richard Exp $
+# $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):
-        from roundup.backends import anydbm
+        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 = anydbm.Database(config, 'admin')
-        setupSchema(self.db, 1, anydbm)
+        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):
-        ''' 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.
-        '''
         ei = self.db.security.addPermission(name="Edit", klass="issue",
                         description="User is allowed to edit issues")
         self.db.security.addPermissionToRole('User', ei)
@@ -52,52 +48,135 @@ class PermissionTest(MyTestCase):
                         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')
-        ei = self.db.security.addPermission(name="Edit", klass="issue",
-                        description="User is allowed to edit issues")
-        self.db.security.getPermission('Edit', 'issue')
-        ai = self.db.security.addPermission(name="View", klass="issue",
-                        description="User is allowed to access issues")
-        self.db.security.getPermission('View', 'issue')
+
+        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="anonymous", roles='User')
+        self.db.user.create(username="demo", roles='User')
+        self.db.user.create(username="anonymous", roles='Anonymous')
 
     def testAccessControls(self):
-        self.testDBinit()
-        ei = self.db.security.addPermission(name="Edit", klass="issue",
-                        description="User is allowed to edit issues")
-        self.db.security.addPermissionToRole('User', ei)
+        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
-        userid = self.db.user.lookup('admin')
-        self.assertEquals(self.db.security.hasPermission('Edit', userid,
-            'issue'), 1)
-        self.assertEquals(self.db.security.hasPermission('Edit', userid,
-            'user'), 1)
-        userid = self.db.user.lookup('anonymous')
-        self.assertEquals(self.db.security.hasPermission('Edit', userid,
-            'issue'), 1)
-        self.assertEquals(self.db.security.hasPermission('Edit', userid,
-            'user'), 0)
-        self.assertEquals(self.db.security.hasPermission('View', userid,
-            'issue'), 0)
-
-        # test node-level access
-        issueid = self.db.issue.create(title='foo', assignedto='admin')
-        userid = self.db.user.lookup('admin')
-        self.assertEquals(self.db.security.hasNodePermission('issue',
-            issueid, assignedto=userid), 1)
-        self.assertEquals(self.db.security.hasNodePermission('issue',
-            issueid, nosy=userid), 0)
-        self.db.issue.set(issueid, nosy=[userid])
-        self.assertEquals(self.db.security.hasNodePermission('issue',
-            issueid, nosy=userid), 1)
+        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()
@@ -108,4 +187,4 @@ if __name__ == '__main__':
     runner = unittest.TextTestRunner()
     unittest.main(testRunner=runner)
 
-# vim: set filetype=python ts=4 sw=4 et si
+# vim: set filetype=python sts=4 sw=4 et si :
index 85f5184eaa0f8c308373aa9d4611b16a4cf9124f..2ea8eea29c10b9fe1f155df21db00f5c2d1dba61 100644 (file)
 # 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.4 2004-03-18 01:58:46 richard Exp $ 
+#
+# $Id: test_sqlite.py,v 1.6 2008-09-01 00:43:02 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:
-    from roundup import backends
-    if hasattr(backends, 'sqlite'):
-        from roundup.backends import sqlite as module
+    if have_backend('sqlite'):
+        module = get_backend('sqlite')
 
     def nuke_database(self):
         shutil.rmtree(config.DATABASE)
@@ -48,7 +48,7 @@ class sqliteSessionTest(sqliteOpener, RDBMSTest):
 def test_suite():
     suite = unittest.TestSuite()
     from roundup import backends
-    if not hasattr(backends, 'sqlite'):
+    if not have_backend('sqlite'):
         print 'Skipping sqlite tests'
         return suite
     print 'Including sqlite tests'
index 12ad0e2e2a4e8eadaba3ca755eaecf9a330d63bb..80bae0efded1cb673c1ef865946d6e8accf8709c 100644 (file)
@@ -12,23 +12,30 @@ class TemplatingTestCase(unittest.TestCase):
     def setUp(self):
         self.form = FieldStorage()
         self.client = MockNull()
-        self.client.db = MockDatabase()
+        self.client.db = db = MockDatabase()
+        db.security.hasPermission = lambda *args, **kw: True
         self.client.form = self.form
 
 class HTMLDatabaseTestCase(TemplatingTestCase):
     def test_HTMLDatabase___getitem__(self):
         db = HTMLDatabase(self.client)
         self.assert_(isinstance(db['issue'], HTMLClass))
-        self.assert_(isinstance(db['user'], HTMLUserClass))
-        self.assert_(isinstance(db['issue1'], HTMLItem))
-        self.assert_(isinstance(db['user1'], HTMLUser))
+        # 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))
-        self.assert_(isinstance(db.user, HTMLUserClass))
-        self.assert_(isinstance(db.issue1, HTMLItem))
-        self.assert_(isinstance(db.user1, HTMLUser))
+        # 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)
@@ -43,6 +50,7 @@ class FunctionsTestCase(TemplatingTestCase):
                 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'])
@@ -62,6 +70,71 @@ class FunctionsTestCase(TemplatingTestCase):
         self.assertEqual(lookupKeys(shrubbery, 'spam', ['ok','2']), ['ok',
             'eggs'])
 
+class HTMLClassTestCase(TemplatingTestCase) :
+
+    def test_multilink(self):
+        """`lookup` of an item will fail if leading or trailing whitespace
+           has not been stripped.
+        """
+        def lookup(key) :
+            self.assertEqual(key, key.strip())
+            return "User%s"%key
+        self.form.list.append(MiniFieldStorage("nosy", "1, 2"))
+        nosy = hyperdb.Multilink("user")
+        self.client.db.classes = dict \
+            ( issue = MockNull(getprops = lambda : dict(nosy = nosy))
+            , user  = MockNull(get = lambda id, name : id, lookup = lookup)
+            )
+        cls = HTMLClass(self.client, "issue")
+        cls["nosy"]
+
+    def test_url_match(self):
+        '''Test the URL regular expression in StringHTMLProperty.
+        '''
+        def t(s, nothing=False, **groups):
+            m = StringHTMLProperty.hyper_re.search(s)
+            if nothing:
+                if m:
+                    self.assertEquals(m, None, '%r matched (%r)'%(s, m.groupdict()))
+                return
+            else:
+                self.assertNotEquals(m, None, '%r did not match'%s)
+            d = m.groupdict()
+            for g in groups:
+                self.assertEquals(d[g], groups[g], '%s %r != %r in %r'%(g, d[g],
+                    groups[g], s))
+
+        #t('123.321.123.321', 'url')
+        t('http://localhost/', url='http://localhost/')
+        t('http://roundup.net/', url='http://roundup.net/')
+        t('http://richard@localhost/', url='http://richard@localhost/')
+        t('http://richard:sekrit@localhost/',
+            url='http://richard:sekrit@localhost/')
+        t('<HTTP://roundup.net/>', url='HTTP://roundup.net/')
+        t('www.a.ex', url='www.a.ex')
+        t('foo.a.ex', nothing=True)
+        t('StDevValidTimeSeries.GetObservation', nothing=True)
+        t('http://a.ex', url='http://a.ex')
+        t('http://a.ex/?foo&bar=baz\\.@!$%()qwerty',
+            url='http://a.ex/?foo&bar=baz\\.@!$%()qwerty')
+        t('www.foo.net', url='www.foo.net')
+        t('richard@com.example', email='richard@com.example')
+        t('r@a.com', email='r@a.com')
+        t('i1', **{'class':'i', 'id':'1'})
+        t('item123', **{'class':'item', 'id':'123'})
+        t('www.user:pass@host.net', email='pass@host.net')
+        t('user:pass@www.host.net', url='user:pass@www.host.net')
+        t('123.35', nothing=True)
+        t('-.3535', nothing=True)
+
+    def test_url_replace(self):
+        p = StringHTMLProperty(self.client, 'test', '1', None, 'test', '')
+        def t(s): return p.hyper_re.sub(p._hyper_repl, s)
+        ae = self.assertEquals
+        ae(t('http://roundup.net/'), '<a href="http://roundup.net/">http://roundup.net/</a>')
+        ae(t('&lt;HTTP://roundup.net/&gt;'), '&lt;<a href="HTTP://roundup.net/">HTTP://roundup.net/</a>&gt;')
+        ae(t('&lt;www.roundup.net&gt;'), '&lt;<a href="http://www.roundup.net">www.roundup.net</a>&gt;')
+
 '''
 class HTMLPermissions:
     def is_edit_ok(self):
@@ -236,9 +309,11 @@ def test_suite():
     suite = unittest.TestSuite()
     suite.addTest(unittest.makeSuite(HTMLDatabaseTestCase))
     suite.addTest(unittest.makeSuite(FunctionsTestCase))
+    suite.addTest(unittest.makeSuite(HTMLClassTestCase))
     return suite
 
 if __name__ == '__main__':
     runner = unittest.TextTestRunner()
     unittest.main(testRunner=runner)
 
+# vim: set et sts=4 sw=4 :
diff --git a/test/test_textfmt.py b/test/test_textfmt.py
new file mode 100644 (file)
index 0000000..9dbef97
--- /dev/null
@@ -0,0 +1,17 @@
+import unittest
+
+from roundup.support import wrap
+
+class WrapTestCase(unittest.TestCase):
+    def testWrap(self):
+        lorem = '''Lorem ipsum dolor sit amet, consectetuer adipiscing elit.'''
+        wrapped = '''Lorem ipsum dolor
+sit amet,
+consectetuer
+adipiscing elit.'''
+        self.assertEquals(wrap(lorem, 20), wrapped)
+
+def test_suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(WrapTestCase))
+    return suite
diff --git a/test/test_tsearch2.py b/test/test_tsearch2.py
new file mode 100644 (file)
index 0000000..2615350
--- /dev/null
@@ -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 :
diff --git a/test/test_userauditor.py b/test/test_userauditor.py
new file mode 100644 (file)
index 0000000..d098280
--- /dev/null
@@ -0,0 +1,108 @@
+# $Id: test_userauditor.py,v 1.4 2007-09-12 21:11:14 jpend Exp $
+
+import os, unittest, shutil
+from db_test_base import setupTracker
+
+class UserAuditorTest(unittest.TestCase):
+    def setUp(self):
+        self.dirname = '_test_user_auditor'
+        self.instance = setupTracker(self.dirname)
+        self.db = self.instance.open('admin')
+
+        try:
+            import pytz
+            self.pytz = True
+        except ImportError:
+            self.pytz = False
+
+        self.db.user.create(username='kyle', address='kyle@example.com',
+            realname='Kyle Broflovski', roles='User')
+
+    def tearDown(self):
+        self.db.close()
+        try:
+            shutil.rmtree(self.dirname)
+        except OSError, error:
+            if error.errno not in (errno.ENOENT, errno.ESRCH): raise
+
+    def testBadTimezones(self):
+        self.assertRaises(ValueError, self.db.user.create, username='eric', timezone='24')
+
+        userid = self.db.user.lookup('kyle')
+
+        self.assertRaises(ValueError, self.db.user.set, userid, timezone='3000')
+        self.assertRaises(ValueError, self.db.user.set, userid, timezone='24')
+        self.assertRaises(ValueError, self.db.user.set, userid, timezone='-24')
+        self.assertRaises(ValueError, self.db.user.set, userid, timezone='-3000')
+
+        if self.pytz:
+            self.assertRaises(ValueError, self.db.user.set, userid, timezone='MiddleOf/Nowhere')
+
+    def testGoodTimezones(self):
+        self.db.user.create(username='test_user01', timezone='12')
+
+        if self.pytz:
+            self.db.user.create(username='test_user02', timezone='MST')
+
+        userid = self.db.user.lookup('kyle')
+
+        # TODO: roundup should accept non-integer offsets since those are valid
+        # this is the offset for Tehran, Iran
+        #self.db.user.set(userid, timezone='3.5')
+
+        self.db.user.set(userid, timezone='-23')
+        self.db.user.set(userid, timezone='23')
+        self.db.user.set(userid, timezone='0')
+
+        if self.pytz:
+            self.db.user.set(userid, timezone='US/Eastern')
+
+    def testBadEmailAddresses(self):
+        userid = self.db.user.lookup('kyle')
+        self.assertRaises(ValueError, self.db.user.set, userid, address='kyle @ example.com')
+        self.assertRaises(ValueError, self.db.user.set, userid, address='one@example.com,two@example.com')
+        self.assertRaises(ValueError, self.db.user.set, userid, address='weird@@example.com')
+        self.assertRaises(ValueError, self.db.user.set, userid, address='embedded\nnewline@example.com')
+        # verify that we check alternates as well
+        self.assertRaises(ValueError, self.db.user.set, userid, alternate_addresses='kyle @ example.com')
+        # make sure we accept local style addresses
+        self.db.user.set(userid, address='kyle')
+        # verify we are case insensitive
+        self.db.user.set(userid, address='kyle@EXAMPLE.COM')
+
+    def testUniqueEmailAddresses(self):
+        self.db.user.create(username='kenny', address='kenny@example.com', alternate_addresses='sp_ken@example.com')
+        self.assertRaises(ValueError, self.db.user.create, username='test_user01', address='kenny@example.com')
+        uid = self.db.user.create(username='eric', address='eric@example.com')
+        self.assertRaises(ValueError, self.db.user.set, uid, address='kenny@example.com')
+
+        # make sure we check alternates
+        self.assertRaises(ValueError, self.db.user.set, uid, address='kenny@example.com')
+        self.assertRaises(ValueError, self.db.user.set, uid, address='sp_ken@example.com')
+        self.assertRaises(ValueError, self.db.user.set, uid, alternate_addresses='kenny@example.com')
+
+    def testBadRoles(self):
+        userid = self.db.user.lookup('kyle')
+        self.assertRaises(ValueError, self.db.user.set, userid, roles='BadRole')
+        self.assertRaises(ValueError, self.db.user.set, userid, roles='User,BadRole')
+
+    def testGoodRoles(self):
+        userid = self.db.user.lookup('kyle')
+        # make sure we handle commas in weird places
+        self.db.user.set(userid, roles='User,')
+        self.db.user.set(userid, roles=',User')
+        # make sure we strip whitespace
+        self.db.user.set(userid, roles='    User   ')
+        # check for all-whitespace (treat as no role)
+        self.db.user.set(userid, roles='   ')
+
+def test_suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(UserAuditorTest))
+    return suite
+
+if __name__ == '__main__':
+    runner = unittest.TextTestRunner()
+    unittest.main(testRunner=runner)
+
+# vim: filetype=python sts=4 sw=4 et si
diff --git a/test/test_xmlrpc.py b/test/test_xmlrpc.py
new file mode 100644 (file)
index 0000000..14e19b4
--- /dev/null
@@ -0,0 +1,114 @@
+#
+# Copyright (C) 2007 Stefan Seefeld
+# All rights reserved.
+# For license terms see the file COPYING.txt.
+#
+
+import unittest, os, shutil, errno, sys, difflib, cgi, re
+
+from roundup.cgi.exceptions import *
+from roundup import init, instance, password, hyperdb, date
+from roundup.xmlrpc import RoundupServer
+from roundup.backends import list_backends
+
+import db_test_base
+
+NEEDS_INSTANCE = 1
+
+class TestCase(unittest.TestCase):
+
+    backend = None
+
+    def setUp(self):
+        self.dirname = '_test_xmlrpc'
+        # set up and open a tracker
+        self.instance = db_test_base.setupTracker(self.dirname, self.backend)
+
+        # open the database
+        self.db = self.instance.open('admin')
+        self.joeid = 'user' + self.db.user.create(username='joe',
+            password=password.Password('random'), address='random@home.org',
+            realname='Joe Random', roles='User')
+
+        self.db.commit()
+        self.db.close()
+
+        self.server = RoundupServer(self.dirname)
+
+    def tearDown(self):
+        try:
+            shutil.rmtree(self.dirname)
+        except OSError, error:
+            if error.errno not in (errno.ENOENT, errno.ESRCH): raise
+
+    def testAccess(self):
+        # Retrieve all three users.
+        results = self.server.list('joe', 'random', 'user', 'id')
+        self.assertEqual(len(results), 3)
+
+        # Obtain data for 'joe'.
+        results = self.server.display('joe', 'random', self.joeid)
+        self.assertEqual(results['username'], 'joe')
+        self.assertEqual(results['realname'], 'Joe Random')
+
+    def testChange(self):
+        # Reset joe's 'realname'.
+        results = self.server.set('joe', 'random', self.joeid,
+            'realname=Joe Doe')
+        results = self.server.display('joe', 'random', self.joeid,
+            'realname')
+        self.assertEqual(results['realname'], 'Joe Doe')
+
+        # check we can't change admin's details
+        self.assertRaises(Unauthorised, self.server.set, 'joe', 'random',
+            'user1', 'realname=Joe Doe')
+
+    def testCreate(self):
+        results = self.server.create('joe', 'random', 'issue', 'title=foo')
+        issueid = 'issue' + results
+        results = self.server.display('joe', 'random', issueid, 'title')
+        self.assertEqual(results['title'], 'foo')
+
+    def testFileCreate(self):
+        results = self.server.create('joe', 'random', 'file', 'content=hello\r\nthere')
+        fileid = 'file' + results
+        results = self.server.display('joe', 'random', fileid, 'content')
+        self.assertEqual(results['content'], 'hello\r\nthere')
+
+    def testAuthUnknown(self):
+        # Unknown user (caught in XMLRPC frontend).
+        self.assertRaises(Unauthorised, self.server.list,
+            'nobody', 'nobody', 'user', 'id')
+
+    def testAuthDeniedEdit(self):
+        # Wrong permissions (caught by roundup security module).
+        self.assertRaises(Unauthorised, self.server.set,
+            'joe', 'random', 'user1', 'realname=someone')
+
+    def testAuthDeniedCreate(self):
+        self.assertRaises(Unauthorised, self.server.create,
+            'joe', 'random', 'user', {'username': 'blah'})
+
+    def testAuthAllowedEdit(self):
+        try:
+            self.server.set('admin', 'sekrit', 'user2', 'realname=someone')
+        except Unauthorised, err:
+            self.fail('raised %s'%err)
+
+    def testAuthAllowedCreate(self):
+        try:
+            self.server.create('admin', 'sekrit', 'user', 'username=blah')
+        except Unauthorised, err:
+            self.fail('raised %s'%err)
+
+def test_suite():
+    suite = unittest.TestSuite()
+    for l in list_backends():
+        dct = dict(backend = l)
+        subcls = type(TestCase)('TestCase_%s'%l, (TestCase,), dct)
+        suite.addTest(unittest.makeSuite(subcls))
+    return suite
+
+if __name__ == '__main__':
+    runner = unittest.TextTestRunner()
+    unittest.main(testRunner=runner)
index 9744b87a625e0b2fcab36c571a0f81d6fe2bfd21..53ad329dae185fa743244a28010910019e8a1b7f 100644 (file)
@@ -22,8 +22,8 @@ class AdminTool(admin.AdminTool):
         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')
+        # get the user class
+        cl = self.get_class('user')
         for userid in cl.list():
             username = cl.get(userid, 'username')
             if username == 'admin':
@@ -32,7 +32,7 @@ class AdminTool(admin.AdminTool):
                 roles = 'Anonymous'
             else:
                 roles = 'User'
-           cl.set(userid, roles=roles)
+            cl.set(userid, roles=roles)
         return 0
 
 if __name__ == '__main__':
diff --git a/tools/load_tracker.py b/tools/load_tracker.py
new file mode 100755 (executable)
index 0000000..a6fdd1b
--- /dev/null
@@ -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
index 536a934a35c0c40a1e05c1efac195de2de561c97..ecbea07a852db50d586d261c41b7f386254c38af 100644 (file)
@@ -1,42 +1,42 @@
-#! /usr/bin/env python\r
-'''\r
-migrate-queries <instance-home> [<instance-home> *]\r
-\r
-Migrate old queries in the specified instances to Roundup 0.6.0+ by\r
-removing the leading ? from their URLs. 0.6.0+ queries do not carry a\r
-leading ?; it is added by the 0.6.0 templating, so old queries lead\r
-to query URLs with a double leading ?? and a consequent 404 Not Found.\r
-'''\r
-__author__ = 'James Kew <jkew@mediabright.co.uk>'\r
-\r
-import sys\r
-import roundup.instance\r
-\r
-if len(sys.argv) == 1:\r
-    print __doc__\r
-    sys.exit(1)\r
-\r
-# Iterate over all instance homes specified in argv.\r
-for home in sys.argv[1:]:\r
-    # Do some basic exception handling to catch bad arguments.\r
-    try:\r
-        instance = roundup.instance.open(home)\r
-    except:\r
-        print 'Cannot open instance home directory %s!' % home\r
-        continue\r
-\r
-    db = instance.open('admin')\r
-\r
-    print 'Migrating active queries in %s (%s):'%(\r
-        instance.config.TRACKER_NAME, home)\r
-    for query in db.query.list():\r
-        url = db.query.get(query, 'url')\r
-        if url[0] == '?':\r
-            url = url[1:]\r
-            print '  Migrating query%s (%s)'%(query,\r
-                db.query.get(query, 'name'))\r
-            db.query.set(query, url=url)\r
-\r
-    db.commit()\r
-    db.close()\r
-\r
+#! /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@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()
+